Compare commits

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

79 commits

Author SHA1 Message Date
9015c78598 . 2025-11-30 15:25:20 +00:00
829a16aa77 Run lint & fmt 2025-11-30 14:52:20 +00:00
953b9fdc9e Add log lines 2025-11-30 14:45:18 +00:00
44d81a94fe Rename method 2025-11-30 14:43:22 +00:00
5e5f8ecc22 Install cargo machete 2025-11-30 14:43:05 +00:00
f973086c17 Add lock on settings 2025-11-30 14:42:50 +00:00
8f2190f6a0 Disallow changing settings while applying previous changes 2025-11-30 14:41:13 +00:00
acffd0006d Log deduping 2025-11-30 11:23:37 +00:00
896014bf38 Format 2025-11-30 11:23:12 +00:00
fc9bb5f491 Fix race condition of client-side path deconflicting 2025-11-29 17:28:03 +00:00
bd5a620942 Don't broadcast without clients 2025-11-29 17:26:09 +00:00
af177813b9 Ignore ds store 2025-11-29 17:18:38 +00:00
686cac16c0 Await settings event handlers 2025-11-29 14:48:42 +00:00
4288b47ace Add copy to clipboard button 2025-11-29 14:24:53 +00:00
2ac5060315 Small clean up 2025-11-29 14:24:15 +00:00
64298dfdc3 Improve logging 2025-11-29 14:22:05 +00:00
696d74ca5e Clean up 2025-11-29 11:02:27 +00:00
755dcc8cf8 Close unsued databases 2025-11-28 21:27:49 +00:00
a2b652559b Add error on duplicate plugin load 2025-11-28 21:27:27 +00:00
9cb5da4de8 Decrease parallelism 2025-11-28 21:24:14 +00:00
29784eb600 Use named group 2025-11-28 21:23:55 +00:00
fbcf2b07a6 Make skipped file a warning 2025-11-28 07:59:29 +00:00
b2eba89bdc Format 2025-11-27 22:21:44 +00:00
66d1448e7e Remove frequent popups 2025-11-27 22:21:37 +00:00
4740cb958b Fix race condition 2025-11-27 22:21:13 +00:00
159c4704de Don't download all documents when initial sync gets interrupted 2025-11-27 21:52:05 +00:00
170183e308 Don't print success twice 2025-11-27 21:47:50 +00:00
fe13f7d30f Improve API 2025-11-27 21:30:17 +00:00
6a82e88730 Fix E2E testing 2025-11-27 21:29:55 +00:00
4434bca654 Fix testing logic 2025-11-27 21:26:27 +00:00
d2356f1e4d Stop leaking promises in ws manager 2025-11-27 21:21:43 +00:00
5796032dda Add api version check to client 2025-11-23 22:12:49 +00:00
687f4a9a11 Add resetting tests 2025-11-23 21:55:57 +00:00
0260ccd5d6 Add server config for mergable extensions 2025-11-23 21:55:33 +00:00
cc297a6cd1 Run check.sh 2025-11-23 20:31:01 +00:00
ca42f614e0 Fix lint 2025-11-23 20:27:16 +00:00
9139b4fa4d Expose new advanced settings 2025-11-23 16:50:03 +00:00
cb0b04206e Fix compile 2025-11-23 16:49:56 +00:00
dbb39a840b Don't leak promises 2025-11-23 16:45:27 +00:00
cf68ff0ec1 Fix resetting 2025-11-23 16:41:42 +00:00
99d90d2e0c Add awaitAll 2025-11-23 15:22:50 +00:00
ac6f44737e Lint 2025-11-23 15:21:36 +00:00
ba8814cedd Lint 2025-11-23 15:13:30 +00:00
35a66a11ce Ban bad methods 2025-11-23 15:12:55 +00:00
c4f40b3549 use allSettled 2025-11-23 15:09:35 +00:00
e51fcf296f Add 2 more settings from consts 2025-11-23 14:59:56 +00:00
05a7a1701e Use updated APIs 2025-11-23 14:24:56 +00:00
b8aefad774 Use new WS api 2025-11-23 14:20:15 +00:00
3764503508 Fix reset logic for WS 2025-11-23 14:18:49 +00:00
eab81bbbbc Renamce 2025-11-23 13:53:54 +00:00
17dcfe300b Add fetch controller tests 2025-11-23 11:03:40 +00:00
b0a7872ab0 Fix fetch controller 2025-11-23 10:43:20 +00:00
fee35a35cd Formatting 2025-11-23 10:42:34 +00:00
aaf6088d62 Extract function 2025-11-22 21:08:24 +00:00
4e88fc9211 Handle move on create 2025-11-22 21:08:16 +00:00
c5ee8e1cd7 Fix dotfile handling 2025-11-22 21:02:30 +00:00
579d0eedfd Extract consts 2025-11-22 20:52:30 +00:00
33782d6509 Dedup paths on create document 2025-11-22 20:50:29 +00:00
3dfafe9ce6 Fix file operations 2025-11-22 20:49:53 +00:00
c02e59034d Add remove event listener methods 2025-11-22 20:35:36 +00:00
8135bc0e27 Export consts 2025-11-22 20:32:07 +00:00
3b2711fcf3 Fix import 2025-11-22 20:30:37 +00:00
31d4343fb1 Have the same error message for file not found 2025-11-22 20:19:13 +00:00
088fad734a Fix edge cases 2025-11-22 20:14:31 +00:00
67cdc18a11 Fix +1 2025-11-22 20:03:09 +00:00
28a72513d1 Replace all instead of just replace 2025-11-22 19:57:52 +00:00
84a44bbc4e Rename param 2025-11-22 19:44:16 +00:00
0fb305a053 Extend 2025-11-22 19:41:24 +00:00
0f4e50d338 Don't kill CI with E2E tests 2025-11-22 12:43:06 +00:00
7f2b3ee928 Enforce british english 2025-11-22 12:42:16 +00:00
dbce35c09f Configure dependabot for docs 2025-11-22 12:40:18 +00:00
48b12fe4ff Refactor plugin setup and avoid dangling resources 2025-11-22 12:38:34 +00:00
c19f1dd5f1 Simplify docs 2025-11-22 12:37:21 +00:00
0dda2d6eac Update docs 2025-11-22 12:13:22 +00:00
42202d91bd Update type imports 2025-11-22 12:00:00 +00:00
b6f3cbc35d All sync-client deps are devDeps 2025-11-22 11:58:18 +00:00
2785a7dd98 Re-export type 2025-11-22 11:46:30 +00:00
61c1433f12 Add docs 2025-11-22 11:19:08 +00:00
d84990ceaa Restructure packages 2025-11-22 11:06:06 +00:00
120 changed files with 9447 additions and 916 deletions

View file

@ -6,7 +6,7 @@
version: 2
updates:
- package-ecosystem: "npm"
directories: ["/frontend"]
directories: ["/frontend", "/docs"]
schedule:
interval: "daily"

75
.github/workflows/deploy-docs.yml vendored Normal file
View file

@ -0,0 +1,75 @@
name: Deploy Documentation
on:
push:
branches:
- main
paths:
- 'docs/**'
- '.github/workflows/deploy-docs.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: docs/package-lock.json
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Install dependencies
run: |
cd docs
npm ci
- name: Check formatting
run: |
cd docs
npm run format:check
- name: Check spelling
run: |
cd docs
npm run spell:check
- name: Build documentation
run: |
cd docs
npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/.vitepress/dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
name: Deploy
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View file

@ -5,6 +5,12 @@ on:
branches: ["main"]
pull_request:
branches: ["main"]
schedule:
- cron: '0 */4 * * *'
concurrency:
group: e2e-tests
cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
@ -42,4 +48,4 @@ jobs:
cargo run config-e2e.yml --color never &
cd ..
scripts/e2e.sh 32
scripts/e2e.sh 8

View file

@ -29,7 +29,10 @@ cd sync-server
cargo run config-e2e.yml # Start development server
cargo test --verbose # Run Rust tests
cargo clippy --all-targets --all-features # Lint Rust code
cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged # Auto-fix clippy warnings
cargo fmt --all -- --check # Check Rust formatting
cargo fmt --all # Auto-format Rust code
cargo machete --with-metadata # Detect unused dependencies
```
### Frontend Development
@ -49,8 +52,15 @@ sqlx migrate run --source src/app_state/database/migrations --database-url sqlit
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
@ -59,10 +69,11 @@ cargo sqlx prepare --workspace
## Code Structure
### Workspace Configuration
The frontend uses npm workspaces with three packages:
The frontend uses npm workspaces with four packages:
- `sync-client`: Core synchronization logic
- `obsidian-plugin`: Obsidian-specific integration
- `test-client`: Testing utilities
- `local-client-cli`: Standalone CLI for VaultLink sync client
### Type Generation
Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/bindings/` and used by frontend packages.

92
docs/.cspell.json Normal file
View file

@ -0,0 +1,92 @@
{
"version": "0.2",
"language": "en-GB",
"dictionaries": ["en-gb"],
"ignorePaths": [
"node_modules",
".vitepress/dist",
".vitepress/cache",
"package-lock.json"
],
"words": [
"VaultLink",
"Obsidian",
"WebSocket",
"SQLite",
"codebase",
"CRDT",
"CRDTs",
"YAML",
"nginx",
"Caddy",
"Traefik",
"systemd",
"localhost",
"vaultlink",
"Axum",
"Tokio",
"SQLx",
"reconcile",
"postgresql",
"VitePress",
"markdownlint",
"filesystem",
"backend",
"frontend",
"macOS",
"CLI",
"API",
"JSON",
"HTTP",
"HTTPS",
"SSL",
"TLS",
"WSS",
"TCP",
"VPS",
"Docker",
"Github",
"Dockerfile",
"dockerignore",
"Rustup",
"PostgreSQL",
"UUID",
"CORS",
"HSTS",
"CI",
"CD",
"OpenSSL",
"README",
"config",
"submodule",
"repo",
"autocomplete",
"autoformat",
"dedupe",
"diff",
"grep",
"stdout",
"stderr",
"chmod",
"mkdir",
"rclone",
"uuidgen",
"letsencrypt",
"fullchain",
"privkey",
"schmelczer",
"Schmelczer",
"ghcr",
"keepalive",
"healthcheck",
"writable",
"Cloudant",
"Syncthing",
"cadvisor",
"Caddyfile",
"nodelay",
"websecure",
"certresolver",
"rootfs"
]
}

4
docs/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
.vitepress/dist/
.vitepress/cache/
package-lock.json

4
docs/.prettierignore Normal file
View file

@ -0,0 +1,4 @@
node_modules/
.vitepress/dist/
.vitepress/cache/
package-lock.json

19
docs/.prettierrc Normal file
View file

@ -0,0 +1,19 @@
{
"printWidth": 120,
"tabWidth": 4,
"useTabs": true,
"semi": false,
"singleQuote": false,
"trailingComma": "none",
"endOfLine": "lf",
"proseWrap": "preserve",
"overrides": [
{
"files": "*.md",
"options": {
"proseWrap": "preserve",
"printWidth": 120
}
}
]
}

View file

@ -0,0 +1,60 @@
import { defineConfig } from "vitepress"
export default defineConfig({
title: "VaultLink",
description: "Self-hosted real-time synchronisation for Obsidian",
base: "/vault-link/",
themeConfig: {
logo: "/logo.svg",
nav: [
{ text: "Home", link: "/" },
{ text: "Guide", link: "/guide/getting-started" },
{ text: "Architecture", link: "/architecture/" },
{ text: "GitHub", link: "https://github.com/schmelczer/vault-link" }
],
sidebar: [
{
text: "Introduction",
items: [
{ text: "What is VaultLink?", link: "/guide/what-is-vaultlink" },
{ text: "Getting Started", link: "/guide/getting-started" },
{ text: "Limitations", link: "/guide/limitations" },
{ text: "Comparison with Alternatives", link: "/guide/alternatives" }
]
},
{
text: "Setup",
items: [
{ text: "Server Setup", link: "/guide/server-setup" },
{ text: "Obsidian Plugin", link: "/guide/obsidian-plugin" },
{ text: "CLI Client", link: "/guide/cli-client" }
]
},
{
text: "Configuration",
items: [
{ text: "Server Configuration", link: "/config/server" },
{ text: "Authentication", link: "/config/authentication" },
{ text: "Advanced Options", link: "/config/advanced" }
]
},
{
text: "Architecture",
items: [
{ text: "Overview", link: "/architecture/" },
{ text: "Sync Algorithm", link: "/architecture/sync-algorithm" },
{ text: "Data Flow", link: "/architecture/data-flow" }
]
}
],
socialLinks: [{ icon: "github", link: "https://github.com/schmelczer/vault-link" }],
footer: {
message: "Released under the MIT License.",
copyright: "Copyright © 2024-present Andras Schmelczer"
},
search: {
provider: "local"
}
},
head: [["link", { rel: "icon", type: "image/svg+xml", href: "/vault-link/logo.svg" }]]
})

159
docs/README.md Normal file
View file

@ -0,0 +1,159 @@
# VaultLink Documentation
This directory contains the VaultLink documentation site built with [VitePress](https://vitepress.dev/).
## Development
### Prerequisites
- Node.js 18+
- npm
### Setup
```bash
cd docs
npm install
```
### Local Development
Start the development server with hot reload:
```bash
npm run dev
```
The site will be available at `http://localhost:5173/vault-link/`
### Build
Build the static site:
```bash
npm run build
```
Output will be in `.vitepress/dist/`
### Preview
Preview the built site:
```bash
npm run preview
```
### Format
Format all markdown and TypeScript files:
```bash
npm run format
```
Check formatting without making changes:
```bash
npm run format:check
```
### Spell Check
Check spelling (British English):
```bash
npm run spell
```
The spell checker enforces British English spellings (e.g., "synchronisation", "optimise", "behaviour").
## Deployment
The documentation is automatically deployed to GitHub Pages when changes are pushed to the `main` branch.
The deployment workflow is configured in `.github/workflows/deploy-docs.yml`.
## Structure
```
docs/
├── .vitepress/
│ └── config.ts # VitePress configuration
├── public/ # Static assets
│ └── logo.svg # VaultLink logo
├── guide/ # User guides
│ ├── what-is-vaultlink.md
│ ├── getting-started.md
│ ├── server-setup.md
│ ├── obsidian-plugin.md
│ └── cli-client.md
├── architecture/ # Architecture documentation
│ ├── index.md
│ ├── sync-algorithm.md
│ └── data-flow.md
├── config/ # Configuration reference
│ ├── server.md
│ ├── authentication.md
│ └── advanced.md
└── index.md # Home page
```
## Writing Documentation
### Language
All documentation uses **British English**. The spell checker enforces this in CI.
### Markdown Features
VitePress supports:
- GitHub Flavoured Markdown
- Custom containers (tip, warning, danger)
- Code syntax highlighting
- Mermaid diagrams
- Emoji :rocket:
### Custom Containers
```markdown
::: tip
This is a tip
:::
::: warning
This is a warning
:::
::: danger
This is a danger message
:::
```
### Code Blocks
````markdown
```bash
npm install
```
```yaml
server:
port: 3000
```
````
## Contributing
When adding new pages:
1. Create the markdown file in the appropriate directory
2. Add it to the sidebar in `.vitepress/config.ts`
3. Test locally with `npm run dev`
4. Submit a pull request
## License
MIT - Same as VaultLink

View file

@ -0,0 +1,553 @@
# Data Flow
How data flows through VaultLink, from client to server and back.
## Connection Lifecycle
### 1. Initial Connection
```mermaid
sequenceDiagram
participant C as Client
participant S as Server
participant DB as Database
C->>S: WebSocket connect
S->>S: Accept connection
C->>S: Auth message (token + vault)
S->>S: Validate token
S->>S: Check vault access
S-->>C: Auth success
Note over C,S: Connection established
```
**Steps**:
1. Client initiates WebSocket connection to server
2. Server accepts connection
3. Client sends authentication message with token and vault name
4. Server validates token against `config.yml`
5. Server checks if user has access to requested vault
6. Server responds with success or error
7. Connection is ready for syncing
### 2. Initial Sync
After authentication, the client performs initial synchronisation:
```mermaid
sequenceDiagram
participant C as Client
participant S as Server
participant DB as SQLite
C->>C: Scan local filesystem
C->>S: Request file list
S->>DB: Query all files
DB-->>S: File metadata
S-->>C: File list with versions
loop For each local file
C->>C: Check if file on server
alt File not on server
C->>S: Upload file
S->>DB: Store file + metadata
else File on server (different version)
C->>C: Compare versions
C->>S: Upload newer or merge
end
end
loop For each server file
C->>C: Check if file local
alt File not local
C->>S: Download file
S->>DB: Retrieve file
DB-->>S: File content
S-->>C: File content
C->>C: Write to disk
end
end
S-->>C: Sync complete message
```
**Process**:
1. Client scans local filesystem
2. Client requests file list from server
3. Server queries database and returns metadata
4. Client uploads missing or changed local files
5. Client downloads missing files from server
6. Server sends sync complete notification
### 3. Real-Time Synchronization
After initial sync, changes are pushed in real-time:
```mermaid
sequenceDiagram
participant FS as Filesystem
participant C1 as Client 1
participant S as Server
participant DB as Database
participant C2 as Client 2
FS->>C1: File changed (fs.watch)
C1->>C1: Read file content
C1->>S: Upload file
S->>DB: Store new version
S->>S: Apply OT if needed
S-->>C1: Upload ACK
S->>C2: File update notification
C2->>S: Download file
S->>DB: Retrieve file
DB-->>S: File content
S-->>C2: File content
C2->>FS: Write to disk
```
**Flow**:
1. Filesystem watcher detects local change
2. Client reads file content
3. Client uploads file via WebSocket
4. Server stores in database
5. Server applies operational transformation if concurrent edits
6. Server acknowledges upload to sender
7. Server broadcasts update to other clients
8. Other clients download and apply changes
## File Operations
### Upload
```
┌─────────┐
│ Client │
└────┬────┘
│ 1. Detect file change
├─► 2. Read file content
├─► 3. Create upload message
│ {
│ type: "upload_file",
│ path: "notes/daily.md",
│ content: "...",
│ version: 42,
│ timestamp: "2024-01-01T12:00:00Z"
│ }
┌─────────┐
│ Server │
└────┬────┘
│ 4. Validate message
├─► 5. Check permissions
├─► 6. Apply OT (if conflicts)
├─► 7. Store in database
├─► 8. Update version
├─► 9. Broadcast to clients
└─► 10. Send ACK to uploader
```
### Download
```
┌─────────┐
│ Server │
└────┬────┘
│ 1. File updated by another client
├─► 2. Broadcast notification
│ {
│ type: "file_updated",
│ path: "notes/daily.md",
│ version: 43
│ }
┌─────────┐
│ Client │
└────┬────┘
│ 3. Receive notification
├─► 4. Request file download
│ {
│ type: "download_file",
│ path: "notes/daily.md",
│ version: 43
│ }
┌─────────┐
│ Server │
└────┬────┘
│ 5. Retrieve from database
└─► 6. Send file content
{
type: "file_content",
path: "notes/daily.md",
content: "...",
version: 43
}
┌─────────┐
│ Client │
└────┬────┘
│ 7. Write to filesystem
└─► 8. Update local metadata
```
### Delete
```
┌─────────┐
│ Client │
└────┬────┘
│ 1. File deleted locally
├─► 2. Send delete message
│ {
│ type: "delete_file",
│ path: "notes/old.md"
│ }
┌─────────┐
│ Server │
└────┬────┘
│ 3. Mark as deleted in DB
│ (soft delete for history)
├─► 4. Broadcast deletion
└─► 5. ACK to sender
┌─────────┐
│ Other │
│ Clients │
└────┬────┘
│ 6. Delete local file
└─► 7. Update metadata
```
## Conflict Resolution Flow
### Concurrent Edits Scenario
```
Time →
Client A Server Client B
│ │ │
│ Edit file v10 │ │
│ "Add line A" │ │ Edit file v10
│ │ │ "Add line B"
│ │ │
├─── Upload @ t1 ─────────►│ │
│ │◄────── Upload @ t2 ────────┤
│ │ │
│ │ 1. Receive both edits │
│ │ (based on v10) │
│ │ │
│ │ 2. Apply first edit │
│ │ → v11 (line A added) │
│ │ │
│ │ 3. Transform second edit │
│ │ against first │
│ │ │
│ │ 4. Apply transformed edit │
│ │ → v12 (both lines) │
│ │ │
│◄──── v12 content ────────┤ │
│ ├───── v12 content ─────────►│
│ │ │
│ Apply v12 │ │ Apply v12
│ (has both lines) │ │ (has both lines)
│ │ │
```
### Conflict Resolution Steps
1. **Detection**: Server receives two edits based on the same version
2. **Ordering**: Determine which edit to apply first (by timestamp or client ID)
3. **First edit**: Apply directly to database
4. **Transformation**: Transform second edit against first using OT
5. **Second edit**: Apply transformed edit to database
6. **Broadcast**: Send merged result to all clients
7. **Application**: Clients apply merged version locally
## Database Schema
### Core Tables
```sql
-- Document metadata
CREATE TABLE documents (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
version INTEGER NOT NULL,
content_hash TEXT,
size INTEGER,
created_at TIMESTAMP,
updated_at TIMESTAMP,
deleted BOOLEAN DEFAULT FALSE
);
-- Version history
CREATE TABLE versions (
id INTEGER PRIMARY KEY,
document_id INTEGER,
version INTEGER,
content BLOB,
created_at TIMESTAMP,
FOREIGN KEY (document_id) REFERENCES documents(id)
);
-- Client sync cursors
CREATE TABLE cursors (
client_id TEXT PRIMARY KEY,
last_version INTEGER,
last_updated TIMESTAMP
);
```
### Queries
**Get files since version**:
```sql
SELECT * FROM documents
WHERE version > ? AND deleted = FALSE
ORDER BY version ASC;
```
**Store new version**:
```sql
INSERT INTO versions (document_id, version, content, created_at)
VALUES (?, ?, ?, ?);
UPDATE documents
SET version = ?, updated_at = ?
WHERE id = ?;
```
**Update cursor**:
```sql
INSERT OR REPLACE INTO cursors (client_id, last_version, last_updated)
VALUES (?, ?, ?);
```
## Message Protocol
### Client → Server Messages
**Upload File**:
```json
{
"type": "upload_file",
"path": "notes/example.md",
"content": "File content here...",
"base_version": 10,
"timestamp": "2024-01-01T12:00:00Z"
}
```
**Download File**:
```json
{
"type": "download_file",
"path": "notes/example.md"
}
```
**Delete File**:
```json
{
"type": "delete_file",
"path": "notes/old.md"
}
```
**List Files**:
```json
{
"type": "list_files",
"since_version": 0
}
```
### Server → Client Messages
**File Updated**:
```json
{
"type": "file_updated",
"path": "notes/example.md",
"version": 11,
"size": 1024,
"hash": "abc123..."
}
```
**File Content**:
```json
{
"type": "file_content",
"path": "notes/example.md",
"content": "Updated content...",
"version": 11
}
```
**File Deleted**:
```json
{
"type": "file_deleted",
"path": "notes/old.md",
"version": 12
}
```
**Sync Complete**:
```json
{
"type": "sync_complete",
"total_files": 150,
"current_version": 200
}
```
**Error**:
```json
{
"type": "error",
"message": "File too large",
"code": "FILE_TOO_LARGE"
}
```
## Error Handling
### Client-Side Errors
**Network failure**:
1. Detect WebSocket disconnect
2. Queue pending operations
3. Retry connection with exponential backoff
4. Replay queued operations on reconnect
**File read error**:
1. Log error
2. Skip file
3. Continue with other files
4. Report to user
**Write conflict**:
1. Receive updated version from server
2. Apply OT merge locally
3. Overwrite local file
4. Continue syncing
### Server-Side Errors
**Database error**:
1. Log error
2. Return error to client
3. Client retries operation
**Invalid operation**:
1. Validate message format
2. Return specific error code
3. Client handles error appropriately
**Authentication failure**:
1. Reject connection
2. Send auth error
3. Client prompts for new credentials
## Performance Optimizations
### Batching
- Small, rapid changes are batched together
- Reduces message overhead
- Applied as single atomic update
### Compression
- Large files compressed before transmission
- Reduces bandwidth usage
- Transparent to application layer
### Incremental Sync
- Only changed portions of files sent
- Uses content-based diffing
- Significantly reduces data transfer
### Caching
- Server caches recent file versions
- Reduces database queries
- Improves response time
## Monitoring Data Flow
### Server Logs
```
2024-01-01 12:00:00 INFO WebSocket connection from 192.168.1.100
2024-01-01 12:00:01 INFO User 'alice' authenticated for vault 'personal'
2024-01-01 12:00:05 INFO Upload: notes/daily.md (v10 -> v11)
2024-01-01 12:00:06 INFO Broadcast to 3 clients
2024-01-01 12:00:10 INFO Conflict resolved: notes/shared.md (v12)
```
### Client Logs
```
2024-01-01 12:00:00 INFO Connecting to ws://sync.example.com
2024-01-01 12:00:01 INFO Connected, authenticating...
2024-01-01 12:00:01 INFO Authentication successful
2024-01-01 12:00:02 INFO Starting initial sync
2024-01-01 12:00:10 INFO Sync complete: 150 files, 200 MB
2024-01-01 12:00:15 INFO Uploaded: notes/daily.md
2024-01-01 12:00:20 INFO Downloaded: notes/shared.md (merged)
```
## Next Steps
- [Understand the sync algorithm →](/architecture/sync-algorithm)
- [Configure the server →](/config/server)
- [Deploy VaultLink →](/guide/getting-started)

334
docs/architecture/index.md Normal file
View file

@ -0,0 +1,334 @@
# Architecture Overview
Central sync server with multiple clients. High-level architecture and design decisions.
## System Components
```
┌─────────────────────────────────────────────────────────────┐
│ Clients │
├─────────────────────┬───────────────────┬───────────────────┤
│ Obsidian Plugin │ Obsidian Plugin │ CLI Client │
│ (User A - Device1) │ (User A - Device2│ (Server/Backup) │
└──────────┬──────────┴─────────┬─────────┴──────────┬────────┘
│ │ │
│ WebSocket │ WebSocket │ WebSocket
│ │ │
└────────────────────┼────────────────────┘
┌───────────▼───────────┐
│ Sync Server │
│ (Rust + Axum) │
│ │
│ ┌─────────────────┐ │
│ │ WebSocket Hub │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ Sync Engine │ │
│ │ (OT Algorithm) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ SQLite Database │ │
│ │ (Per Vault) │ │
│ └─────────────────┘ │
└───────────────────────┘
```
## Core Components
### Sync Server
Central authority for synchronisation. Rust + Axum framework.
**Responsibilities**:
- Accept WebSocket connections from clients
- Authenticate users via token-based auth
- Store document versions in SQLite
- Coordinate real-time updates between clients
- Apply operational transformation for conflict resolution
- Manage vault access control
**Technology**:
- **Language**: Rust 1.89+
- **Framework**: Axum (async web framework)
- **Database**: SQLite with SQLx
- **Protocol**: WebSockets for real-time communication
- **Sync Algorithm**: reconcile-text (operational transformation)
### Sync Client Library
TypeScript library with core sync logic. Used by Obsidian plugin and CLI client.
**Responsibilities**:
- Manage WebSocket connection to server
- Watch local filesystem for changes
- Upload and download files
- Apply remote changes locally
- Handle conflict resolution
- Maintain sync metadata
**Technology**:
- **Language**: TypeScript
- **Build**: Webpack
- **Protocol**: WebSocket client
- **File System**: Node.js `fs` API / Obsidian API
### Obsidian Plugin
Integration layer between sync client and Obsidian.
**Responsibilities**:
- Provide UI for configuration
- Bridge sync client with Obsidian's file system API
- Handle Obsidian lifecycle events
- Display sync status to users
**Technology**:
- **Platform**: Obsidian Plugin API
- **Core**: sync-client library
- **UI**: Obsidian settings UI
### CLI Client
Standalone executable for syncing vaults without Obsidian.
**Responsibilities**:
- Command-line interface
- File system access via Node.js
- Daemon mode for continuous sync
- Health check endpoint for monitoring
**Technology**:
- **Language**: TypeScript
- **Runtime**: Node.js
- **CLI**: Commander.js
- **Core**: sync-client library
## Data Flow
### Initial Connection
1. Client connects via WebSocket to server
2. Server authenticates using provided token
3. Server verifies user has access to requested vault
4. Connection established, sync begins
### File Upload Flow
```
Client Server
│ │
│ 1. File changed locally │
│ │
│ 2. Read file content │
│ │
│ 3. WebSocket: Upload file │
├──────────────────────────────►│
│ │ 4. Store in SQLite
│ │
│ │ 5. Broadcast to other clients
│ ├───────────────────────►
│ 6. Ack upload │
│◄──────────────────────────────┤
```
### File Download Flow
```
Client A Server Client B
│ │ │
│ │ 1. File uploaded │
│ │◄────────────────────────┤
│ │ │
│ │ 2. Store in DB │
│ │ │
│ 3. Push notification │ │
│◄────────────────────────┤ │
│ │ │
│ 4. Download file │ │
├────────────────────────►│ │
│ │ │
│ 5. Write locally │ │
│ │ │
```
### Conflict Resolution
When two clients edit the same file simultaneously:
```
Client A Server Client B
│ │ │
│ 1. Edit file │ │ 1. Edit same file
│ │ │
│ 2. Upload changes │ │ 2. Upload changes
├────────────────────────►│◄────────────────────────┤
│ │ │
│ │ 3. Apply OT algorithm │
│ │ - Merge both edits │
│ │ - Preserve all changes│
│ │ │
│ 4. Receive merged ver. │ 5. Receive merged ver. │
│◄────────────────────────┤────────────────────────►│
│ │ │
│ 6. Apply locally │ │ 6. Apply locally
```
## Storage Architecture
### Server Storage
Each vault has its own SQLite database:
```
databases/
├── vault-1.db
├── vault-2.db
└── shared-team.db
```
**Database Schema** (simplified):
- **documents**: File metadata (path, size, modified time)
- **versions**: Document content with version history
- **cursors**: Client sync state
### Client Storage
Clients maintain sync metadata:
```
.vaultlink/
├── metadata.json # Sync state
└── cache/ # Optional local cache
```
The `.vaultlink` directory tracks which files have been synced and their versions to enable efficient synchronisation.
## Communication Protocol
### WebSocket Messages
Client-server communication uses JSON messages over WebSocket.
**Message Types**:
- `upload_file`: Client → Server (file upload)
- `download_file`: Client → Server (request file)
- `file_updated`: Server → Client (file changed notification)
- `file_deleted`: Server → Client (file deleted notification)
- `sync_complete`: Server → Client (initial sync finished)
### Authentication
Token-based authentication on connection:
```typescript
// Client sends token on connect
{
type: "auth",
token: "user-auth-token",
vault: "vault-name"
}
// Server responds
{
type: "auth_success"
}
// or
{
type: "auth_error",
message: "Invalid token"
}
```
## Scalability Considerations
### Current Architecture
- **SQLite per vault**: Simple, performant, limited to single server
- **WebSocket connections**: Stateful, requires sticky sessions for load balancing
- **Operational transformation**: Centralized on server
### Scaling Approaches
**Vertical Scaling**:
- Increase server resources (CPU, RAM, storage)
- Optimize database queries and indexing
- Tune connection limits
**Horizontal Scaling** (future):
- Separate vault servers (vault sharding)
- Load balancer with sticky sessions
- Shared storage layer for SQLite databases
- Consider alternative databases (PostgreSQL) for multi-server setups
### Performance Characteristics
- **Small vaults** (< 1000 files): Excellent performance
- **Medium vaults** (1000-10000 files): Good performance with tuning
- **Large vaults** (> 10000 files): May require optimisation
- **Concurrent users**: Tested with dozens of simultaneous clients per vault
## Security Model
### Authentication
- Token-based authentication
- Tokens configured in server `config.yml`
- No password hashing (tokens are secrets)
### Authorization
- Per-user vault access control
- Allow-list or deny-list patterns
- Global access or vault-specific access
### Network Security
- WebSocket over TLS (WSS) for encrypted transport
- No built-in SSL (use reverse proxy)
- CORS configured for web clients
### Data Security
- No encryption at rest (use encrypted filesystems if needed)
- No end-to-end encryption (server sees all content)
- Self-hosted model: you control the data
## Technology Choices
**Rust**: Low latency, memory safe, excellent async with Tokio, compile-time SQL verification
**SQLite**: No separate database server, fast for reads, single file per vault, backups are file copies
**WebSocket**: Bidirectional push, no polling overhead, built-in browser/Node.js support
**Operational Transformation**: Automatic conflict resolution, preserves all edits, real-time collaboration
## Design Principles
1. **Self-hosted first**: Users control their data and infrastructure
2. **Simplicity**: Easy to deploy and operate
3. **Real-time**: Changes appear immediately
4. **Reliability**: Handle network failures gracefully
5. **Performance**: Fast sync for typical vault sizes
6. **Privacy**: No third-party services or telemetry
## Next Steps
- [Learn about the sync algorithm →](/architecture/sync-algorithm)
- [Understand data flow in detail →](/architecture/data-flow)
- [Deploy the server →](/guide/server-setup)

View file

@ -0,0 +1,438 @@
# Sync Algorithm
VaultLink uses operational transformation (OT) to handle concurrent edits and maintain consistency across clients.
## Operational Transformation
Operational transformation is a technique for managing concurrent edits to the same document. It transforms operations (edits) so they can be applied in different orders while preserving user intent.
### Why OT?
Traditional conflict resolution approaches:
- **Last write wins**: Loses data, frustrating for users
- **Manual merging**: Interrupts workflow, requires user intervention
- **Version branching**: Complex, not suitable for real-time sync
Operational transformation:
- **Automatic**: No user intervention required
- **Preserves all edits**: No data loss
- **Real-time**: Changes appear immediately
- **Intuitive**: Behaviour matches user expectations
## The reconcile-text Library
VaultLink uses the [`reconcile-text`](https://crates.io/crates/reconcile-text) Rust library for operational transformation on text documents.
### Why reconcile-text over CRDTs?
VaultLink faces a **differential synchronisation** challenge: users edit Obsidian vaults with various editors (Obsidian desktop, Obsidian mobile, Vim, VS Code, or any text editor), often while offline. This means we only observe the **final state** of each document after editing, not the individual keystrokes or operations that produced it.
**The fundamental problem**:
- **CRDTs and traditional OT** require capturing every individual operation (each character insertion, deletion, cursor movement)
- **VaultLink's reality**: Users edit files with arbitrary tools, sync happens after the fact
- **What we know**: Parent version and two modified versions
- **What we don't know**: The sequence of operations that created those modifications
**Why reconcile-text wins for this use case**:
1. **Works with end states only**: reconcile-text performs conflict-free 3-way merging given just parent, left, and right versions—no operation history needed
2. **Editor-agnostic**: Users can edit with any tool without requiring VaultLink-specific plugins or operation tracking
3. **Offline-first**: Edits made while disconnected are merged cleanly when sync resumes, because we're diffing final states rather than replaying operations
4. **No conflict markers**: Unlike Git merge, produces clean merged output without `<<<<<<<` markers that interrupt note-taking flow
5. **Human text forgiveness**: For knowledge bases and documentation, a slightly imperfect merge (e.g., minor word order issues) is vastly preferable to manual conflict resolution
6. **Simpler infrastructure**: No need for complex operation capture, transformation logs, or tombstone management that CRDTs require
**The trade-off**:
CRDTs excel when you control the entire editing infrastructure and can capture every operation. reconcile-text excels when you're synchronising independently-edited files—exactly VaultLink's scenario. The merge quality depends on Myers' diff algorithm rather than operation history, which is the correct trade-off for differential sync.
For note-taking workflows where users value editor freedom and offline editing, this approach provides superior user experience compared to either CRDTs (which would require operation tracking) or Git-style merging (which requires manual conflict resolution).
[Learn more about reconcile-text →](https://schmelczer.dev/reconcile)
### How It Works
Given three versions (parent, left, right), reconcile-text produces a merged result.
**How reconcile-text works**:
1. **Tokenisation**: Split text into words (using `BuiltinTokenizer::Word`)
2. **Three-way diff**: Compare parent→left and parent→right changes
3. **Merge**: Combine non-conflicting changes, prefer content preservation for conflicts
4. **Result**: Merged text with both edits applied
**Example**:
```
Parent: "The quick brown fox"
User A: "The quick red fox" (changes "brown" → "red")
User B: "The very quick brown fox" (inserts "very ")
Merged: "The very quick red fox" (both changes applied)
```
**Merge conditions**: Only `.md` and `.txt` files with valid UTF-8 get merged. Binary files or other extensions use last-write-wins.
### Operation Types
The algorithm handles these operations:
- **Insert**: Add text at position
- **Delete**: Remove text from position
- **Retain**: Keep existing text unchanged
### Transformation Process
1. **Client A** makes edit and sends to server
2. **Client B** makes concurrent edit and sends to server
3. **Server** receives both edits
4. **Server** transforms operations to account for concurrent changes
5. **Server** applies merged result to database
6. **Server** sends transformed operations to both clients
7. **Clients** apply transformed operations locally
## Sync State Management
VaultLink maintains sync state to track which changes have been applied.
### Version Vectors
Each document has a version tracked by:
- **Server version**: Incremented on each change
- **Client cursors**: Track which version each client has seen
This enables:
- Efficient syncing (only send changes since last sync)
- Conflict detection (concurrent edits to same version)
- Ordering of operations
### Cursor Management
Clients maintain a cursor position:
```rust
struct Cursor {
vault_id: String,
client_id: String,
last_version: u64,
last_updated: DateTime,
}
```
On sync:
1. Client sends cursor (last seen version)
2. Server returns all changes since that version
3. Client applies changes and updates cursor
## Conflict Resolution Flow
### Scenario: Concurrent Edits
Two users edit the same paragraph simultaneously.
**Initial state**:
```
Version 10: "The quick brown fox jumps over the lazy dog."
```
**User A's edit** (version 11):
```
"The quick brown fox jumps over the very lazy dog."
```
_Inserts "very " at position 40_
**User B's edit** (also from version 10):
```
"The quick red fox jumps over the lazy dog."
```
_Replaces "brown" with "red" at position 10_
### Server Processing
1. **Receive User A's operation**:
- Base: version 10
- Operation: Insert("very ", position=40)
- Apply to database → version 11
2. **Receive User B's operation**:
- Base: version 10
- Operation: Replace("brown"→"red", position=10)
- **Conflict detected**: Base is version 10, but current is version 11
3. **Transform User B's operation**:
- Transform against User A's operation
- Adjust positions/content as needed
- Apply transformed operation → version 12
4. **Broadcast updates**:
- Send User A's operation to User B
- Send transformed User B's operation to User A
### Final Result
```
Version 12: "The quick red fox jumps over the very lazy dog."
```
Both edits are preserved in the final document.
## Edge Cases
### 1. Delete vs Insert Conflict
**Scenario**: User A deletes a paragraph while User B edits it.
**Resolution**:
- OT algorithm prioritizes preservation of content
- Insert operation is transformed to account for deletion
- Typically results in inserted content appearing nearby
**Example**:
```
Base: "Line 1\nLine 2\nLine 3"
User A: Delete Line 2 → "Line 1\nLine 3"
User B: Edit Line 2 → "Line 1\nLine 2 modified\nLine 3"
Result: "Line 1\nLine 2 modified\nLine 3"
```
(Insert takes precedence, preserving user content)
### 2. Overlapping Edits
**Scenario**: Two users edit overlapping regions.
**Resolution**:
- OT splits operations into non-overlapping segments
- Applies each segment independently
- Merges results
### 3. Delete vs Delete
**Scenario**: Two users delete overlapping text.
**Resolution**:
- Deletes are merged
- Final result has the union of deleted ranges removed
### 4. Network Partitions
**Scenario**: Client loses connection, makes edits offline, reconnects.
**Resolution**:
1. Client queues edits locally
2. On reconnect, sends all queued operations
3. Server applies OT against all operations that happened during partition
4. Client receives transformed operations and applies
## Performance Characteristics
### Time Complexity
- **Single operation**: O(1) for most operations
- **Transformation**: O(n) where n is operation size
- **Conflict resolution**: O(m × n) where m is number of concurrent operations
### Space Complexity
- **Version history**: Grows with number of changes
- **Cursors**: O(clients × vaults)
- **Active operations**: Minimal (processed in real-time)
### Optimisation
VaultLink optimises for:
- Small, frequent edits (typical typing patterns)
- Text documents (not binary files)
- Real-time processing (no batching delay)
## Limitations
### Binary and Non-Mergeable Files
Only **`.md`** and **`.txt`** files get automatic merging. Everything else uses last-write-wins.
**Binary detection**:
- Files with NUL bytes (`0x00`)
- Files failing UTF-8 validation
Even `.md` files are treated as binary if they fail UTF-8 checks.
**Last-write-wins behaviour**:
```
User A uploads image.png → Server version 1
User B uploads image.png → Server version 2 (A's upload lost)
```
**Workaround**: Avoid concurrent edits to non-text files. [See all limitations →](/guide/limitations)
### Large Documents
Very large documents (> 1MB) may have:
- Higher transformation costs
- Slower sync times
- Increased memory usage
**Workaround**: Split large documents or increase timeout settings.
### Complex Formatting
Markdown with complex structures may occasionally produce unexpected results:
- Nested lists
- Tables
- Code blocks
**Workaround**: Manual cleanup if needed, or minimize concurrent edits to complex structures.
## Consistency Guarantees
### Strong Consistency
VaultLink provides **strong eventual consistency**:
- All clients eventually converge to the same state
- Operations applied in causal order
- No data loss under normal operation
### Ordering Guarantees
- Operations from the same client are applied in order
- Concurrent operations may be applied in any order
- Final result is independent of operation order (commutative)
### Durability
- Operations are written to SQLite before acknowledgment
- SQLite ACID guarantees protect against data loss
- Clients retry failed uploads
## Comparison with Other Approaches
### Git-style Merging
| Aspect | Git Merge | VaultLink OT |
| -------------------------- | ------------ | ----------------------- |
| Real-time | No | Yes |
| Manual conflict resolution | Yes | No |
| Branching | Yes | No |
| Automatic merge | Limited | Always |
| Use case | Code changes | Collaborative documents |
### CRDTs (Conflict-free Replicated Data Types)
| Aspect | CRDTs | VaultLink (reconcile-text) |
| ----------------------------- | ------------------------------------ | ------------------------------------------------- |
| **Operation tracking** | Required (every keystroke) | Not required (end states only) |
| **Editor freedom** | Limited (must use CRDT-aware editor) | Unlimited (any text editor works) |
| **Offline editing** | Requires operation log | Works with file comparison |
| **Server required** | No | Yes |
| **Memory overhead** | Higher (tombstones, metadata) | Lower (versions only) |
| **Infrastructure complexity** | Higher | Lower |
| **Best for** | Controlled editing environments | Independent file editing (Obsidian, Vim, VS Code) |
**Key insight**: CRDTs are superior when you can capture every operation. reconcile-text is superior when users edit files independently with arbitrary tools—exactly VaultLink's scenario.
### Last Write Wins
| Aspect | LWW | VaultLink OT |
| --------------- | ---- | ------------ |
| Data loss | Yes | No |
| Simplicity | High | Medium |
| User experience | Poor | Excellent |
| Performance | Best | Good |
## Algorithm Details
### Transformation Rules
When transforming operation `A` against operation `B`:
1. **Insert vs Insert**:
- If positions equal: Order by client ID
- If different positions: Adjust positions
2. **Insert vs Delete**:
- If insert in deleted range: Shift insert position
- If insert after delete: Adjust position by deleted length
3. **Delete vs Delete**:
- If ranges overlap: Merge delete ranges
- If ranges disjoint: Adjust positions
4. **Retain vs Any**:
- Retain operations don't conflict
- Simply adjust positions
### Transformation Example
```rust
// Pseudo-code for transformation
fn transform(op_a: Operation, op_b: Operation) -> (Operation, Operation) {
match (op_a, op_b) {
(Insert(pos_a, text_a), Insert(pos_b, text_b)) => {
if pos_a < pos_b {
(op_a, Insert(pos_b + text_a.len(), text_b))
} else if pos_a > pos_b {
(Insert(pos_a + text_b.len(), text_a), op_b)
} else {
// Same position, use client ID to break tie
if client_id_a < client_id_b {
(op_a, Insert(pos_b + text_a.len(), text_b))
} else {
(Insert(pos_a + text_b.len(), text_a), op_b)
}
}
}
// ... other cases
}
}
```
## Best Practices
### For Smooth Collaboration
1. **Small edits**: Make small, focused changes for easier merging
2. **Coordinate major changes**: Discuss large refactors with team
3. **Monitor sync status**: Ensure changes are uploaded before signing off
4. **Test conflict resolution**: Verify behaviour matches expectations
### For Developers
1. **Text files preferred**: OT works best on text
2. **Limit file sizes**: Keep documents reasonably sized
3. **Binary files**: Use versioning or avoid concurrent edits
4. **Testing**: Test concurrent edit scenarios thoroughly
## Further Reading
- [reconcile-text library](https://crates.io/crates/reconcile-text)
- [Operational Transformation FAQ](https://en.wikipedia.org/wiki/Operational_transformation)
- [Data flow architecture →](/architecture/data-flow)

603
docs/config/advanced.md Normal file
View file

@ -0,0 +1,603 @@
# Advanced Configuration
Advanced topics for optimising and customising your VaultLink deployment.
## Database Optimisation
### SQLite Tuning
While VaultLink handles most SQLite configuration automatically, you can optimise for specific workloads.
#### WAL Mode
VaultLink uses Write-Ahead Logging (WAL) mode by default for better concurrency.
**Benefits**:
- Readers don't block writers
- Writers don't block readers
- Better performance for concurrent access
**Maintenance**:
```bash
# Checkpoint WAL to main database (run periodically)
sqlite3 databases/vault.db "PRAGMA wal_checkpoint(TRUNCATE);"
```
#### Database Size Management
Over time, databases can grow with version history:
```bash
# Check database size
du -h databases/*.db
# Vacuum to reclaim space (offline only)
sqlite3 databases/vault.db "VACUUM;"
# Analyse for query optimisation
sqlite3 databases/vault.db "ANALYZE;"
```
**Schedule maintenance**:
```bash
#!/bin/bash
# monthly-maintenance.sh
for db in databases/*.db; do
echo "Optimising $db"
sqlite3 "$db" "PRAGMA optimize;"
sqlite3 "$db" "PRAGMA wal_checkpoint(TRUNCATE);"
done
```
### Version History Cleanup
VaultLink stores **all versions indefinitely** by default. Database grows with every change.
**Database schema**: Each version stored in `documents` table with `vault_update_id` (sequential).
Manual cleanup (keep last 100 versions per document):
```bash
#!/bin/bash
# prune-old-versions.sh
for db in databases/*.db; do
sqlite3 "$db" <<EOF
DELETE FROM documents
WHERE vault_update_id NOT IN (
SELECT vault_update_id FROM documents d2
WHERE d2.document_id = documents.document_id
ORDER BY vault_update_id DESC
LIMIT 100
);
EOF
done
```
**Warning**: This deletes old versions permanently. No undo.
Run monthly via cron:
```bash
0 3 1 * * /opt/vaultlink/prune-old-versions.sh
```
## Performance Tuning
### Connection Pool Sizing
Calculate optimal `max_connections_per_vault`:
```
max_connections = (concurrent_users × avg_operations_per_user) + buffer
```
**Example**:
- 20 concurrent users
- 2 operations per user on average
- 25% buffer
```
max_connections = (20 × 2) × 1.25 = 50
```
### Timeout Configuration
Adjust timeouts based on network characteristics:
**Fast local network**:
```yaml
database:
cursor_timeout_seconds: 30
server:
response_timeout_seconds: 30
```
**Slow or unreliable network**:
```yaml
database:
cursor_timeout_seconds: 180
server:
response_timeout_seconds: 120
```
**Mobile clients**:
```yaml
database:
cursor_timeout_seconds: 300 # Longer for intermittent connections
server:
response_timeout_seconds: 180
```
## Reverse Proxy Configuration
### Nginx with SSL
Complete Nginx configuration for production:
```nginx
# Rate limiting
limit_req_zone $binary_remote_addr zone=vaultlink:10m rate=10r/s;
upstream vaultlink {
server localhost:3000;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name sync.example.com;
ssl_certificate /etc/letsencrypt/live/sync.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/sync.example.com/privkey.pem;
# SSL security settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# HSTS
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Rate limiting
limit_req zone=vaultlink burst=20 nodelay;
# Client body size (match server config)
client_max_body_size 512M;
# Timeouts
proxy_connect_timeout 90s;
proxy_send_timeout 90s;
proxy_read_timeout 3600s; # WebSocket long-lived connections
# WebSocket headers
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Disable buffering for WebSocket
proxy_buffering off;
location / {
proxy_pass http://vaultlink;
}
# Health check endpoint (use any vault name)
location /health {
proxy_pass http://vaultlink/vaults/test/ping;
access_log off;
}
}
# Redirect HTTP to HTTPS
server {
listen 80;
server_name sync.example.com;
return 301 https://$server_name$request_uri;
}
```
### Caddy with Auto SSL
Caddy handles SSL automatically:
```caddy
sync.example.com {
reverse_proxy localhost:3000 {
# WebSocket support
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
# Timeouts
transport http {
read_timeout 3600s
write_timeout 90s
}
}
# Rate limiting (requires caddy-rate-limit plugin)
rate_limit {
zone dynamic {
match {
remote_ip
}
rate 10r/s
burst 20
}
}
}
```
### Traefik Configuration
Using Docker labels:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
labels:
- "traefik.enable=true"
- "traefik.http.routers.vaultlink.rule=Host(`sync.example.com`)"
- "traefik.http.routers.vaultlink.entrypoints=websecure"
- "traefik.http.routers.vaultlink.tls.certresolver=letsencrypt"
- "traefik.http.services.vaultlink.loadbalancer.server.port=3000"
# Middleware for timeouts
- "traefik.http.middlewares.vaultlink-timeout.timeout.request=3600s"
```
## Docker Optimizations
### Resource Limits
Limit container resources:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
deploy:
resources:
limits:
cpus: "2.0"
memory: 4G
reservations:
cpus: "1.0"
memory: 2G
```
### Logging Configuration
Optimize Docker logging:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "5"
```
### Volume Optimization
Use named volumes for better performance:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
volumes:
- vaultlink-data:/data
- vaultlink-logs:/data/logs
volumes:
vaultlink-data:
driver: local
driver_opts:
type: none
o: bind
device: /mnt/fast-ssd/vaultlink
vaultlink-logs:
driver: local
```
## High Availability
### Health Checks
Comprehensive health monitoring:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:3000/vaults/test/ping || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
```
Monitor health in production:
```bash
#!/bin/bash
# health-monitor.sh
while true; do
if ! curl -sf http://localhost:3000/vaults/test/ping > /dev/null; then
echo "Health check failed at $(date)" | mail -s "VaultLink Down" admin@example.com
# Optionally restart
# docker restart vaultlink-server
fi
sleep 30
done
```
### Backup Automation
Automated backup script:
```bash
#!/bin/bash
# backup-vaultlink.sh
BACKUP_DIR="/backup/vaultlink"
DATA_DIR="/data"
DATE=$(date +%Y%m%d-%H%M%S)
RETENTION_DAYS=30
# Create backup directory
mkdir -p "$BACKUP_DIR/$DATE"
# Backup databases (with WAL checkpoint)
for db in "$DATA_DIR"/databases/*.db; do
sqlite3 "$db" "PRAGMA wal_checkpoint(TRUNCATE);"
cp "$db" "$BACKUP_DIR/$DATE/"
[ -f "${db}-wal" ] && cp "${db}-wal" "$BACKUP_DIR/$DATE/"
[ -f "${db}-shm" ] && cp "${db}-shm" "$BACKUP_DIR/$DATE/"
done
# Backup configuration
cp "$DATA_DIR/config.yml" "$BACKUP_DIR/$DATE/"
# Compress backup
tar -czf "$BACKUP_DIR/vaultlink-$DATE.tar.gz" -C "$BACKUP_DIR" "$DATE"
rm -rf "$BACKUP_DIR/$DATE"
# Clean old backups
find "$BACKUP_DIR" -name "vaultlink-*.tar.gz" -mtime +$RETENTION_DAYS -delete
# Upload to remote storage (optional)
# rclone copy "$BACKUP_DIR/vaultlink-$DATE.tar.gz" remote:backups/
```
Schedule with cron:
```cron
0 2 * * * /opt/vaultlink/backup-vaultlink.sh
```
### Restore from Backup
```bash
#!/bin/bash
# restore-vaultlink.sh
BACKUP_FILE="$1"
DATA_DIR="/data"
if [ -z "$BACKUP_FILE" ]; then
echo "Usage: $0 <backup-file.tar.gz>"
exit 1
fi
# Stop server
docker stop vaultlink-server
# Extract backup
tar -xzf "$BACKUP_FILE" -C /tmp/
BACKUP_DATE=$(basename "$BACKUP_FILE" .tar.gz | cut -d- -f2-)
# Restore databases
cp /tmp/"$BACKUP_DATE"/databases/*.db "$DATA_DIR/databases/"
# Restore config (careful!)
# cp /tmp/$BACKUP_DATE/config.yml "$DATA_DIR/"
# Cleanup
rm -rf /tmp/"$BACKUP_DATE"
# Start server
docker start vaultlink-server
echo "Restore complete. Check server logs."
```
## Monitoring and Metrics
### Prometheus Metrics
While VaultLink doesn't expose metrics natively, monitor Docker:
```yaml
# docker-compose.yml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
labels:
- "prometheus.io/scrape=true"
- "prometheus.io/port=3000"
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
ports:
- 8080:8080
```
### Log Analysis
Analyze logs for insights:
```bash
# Most active users
grep "authenticated" logs/*.log | cut -d"'" -f2 | sort | uniq -c | sort -rn
# Failed authentications by IP
grep "Authentication failed" logs/*.log | grep -oP '\d+\.\d+\.\d+\.\d+' | sort | uniq -c | sort -rn
# Upload activity
grep "Upload:" logs/*.log | wc -l
# Average files per vault
grep "Sync complete" logs/*.log | grep -oP '\d+ files' | cut -d' ' -f1 | awk '{sum+=$1; count++} END {print sum/count}'
```
### Alerting
Simple alerting with cron:
```bash
#!/bin/bash
# alert-errors.sh
ERROR_THRESHOLD=10
ERROR_COUNT=$(grep -c "ERROR" logs/latest.log)
if [ "$ERROR_COUNT" -gt "$ERROR_THRESHOLD" ]; then
echo "VaultLink has $ERROR_COUNT errors in the last hour" | \
mail -s "VaultLink Alert" admin@example.com
fi
```
## Security Hardening
### Network Isolation
Run VaultLink in isolated network:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
networks:
- vaultlink-internal
- proxy-external
networks:
vaultlink-internal:
internal: true
proxy-external:
driver: bridge
```
### Read-Only Root Filesystem
Run with read-only root (mount writable volumes for data):
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
read_only: true
volumes:
- ./data:/data
- /tmp
```
### Drop Capabilities
Run with minimal privileges:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
```
## Migration
### Moving to New Server
1. **Backup on old server**:
```bash
./backup-vaultlink.sh
```
2. **Transfer backup**:
```bash
scp vaultlink-backup.tar.gz new-server:/tmp/
```
3. **Restore on new server**:
```bash
./restore-vaultlink.sh /tmp/vaultlink-backup.tar.gz
```
4. **Update DNS/clients** to point to new server
5. **Verify sync** on all clients
### Version Upgrades
```bash
# Pull latest image
docker pull ghcr.io/schmelczer/vault-link-server:latest
# Backup first
./backup-vaultlink.sh
# Stop old container
docker stop vaultlink-server
docker rm vaultlink-server
# Start with new image
docker run -d \
--name vaultlink-server \
--restart unless-stopped \
-p 3000:3000 \
-v ./data:/data \
ghcr.io/schmelczer/vault-link-server:latest \
/app/sync_server /data/config.yml
# Check logs
docker logs -f vaultlink-server
```
## Next Steps
- [Understand the architecture →](/architecture/)
- [Deploy the server →](/guide/server-setup)
- [Configure clients →](/guide/obsidian-plugin)

View file

@ -0,0 +1,558 @@
# Authentication Configuration
VaultLink uses token-based authentication with per-user vault access control. This guide covers all authentication and authorization options.
## Overview
Authentication in VaultLink:
- **Token-based**: Users authenticate with secure tokens
- **Configured in YAML**: All users defined in `config.yml`
- **Vault-level access**: Control which vaults each user can access
- **No password hashing**: Tokens are treated as secrets
## Basic Configuration
```yaml
users:
user_configs:
- name: alice
token: alice-secure-token-here
vault_access:
type: allow_access_to_all
```
## User Configuration Fields
### `name`
**Type**: String
**Required**: Yes
Human-readable identifier for the user. Used in logs and auditing.
```yaml
- name: alice
```
**Notes**:
- Must be unique across all users
- Used for identification only, not authentication
- Appears in server logs
- Can be any string (e.g., email, username)
### `token`
**Type**: String
**Required**: Yes
Authentication token for the user. Must be kept secret.
```yaml
- token: 1a2b3c4d5e6f7g8h9i0j...
```
**Best practices**:
- Generate with: `openssl rand -hex 32`
- Minimum length: 32 characters
- Use different token per user
- Never commit to version control
- Rotate periodically
**Example token generation**:
```bash
# Generate a secure token
openssl rand -hex 32
# Output: a7f3c9d1e8b2f4a6c3d9e1f7b8a4c2d6e9f1a3b7c5d8e2f4a6b9c3d1e8f7a4b2
```
### `vault_access`
**Type**: Object
**Required**: Yes
Defines which vaults the user can access.
**Three modes**:
1. `allow_access_to_all`: Access to all vaults
2. `allow_list`: Access to specific vaults only
3. `deny_list`: Access to all vaults except specific ones
## Access Control Modes
### Allow Access to All
Grant access to every vault:
```yaml
users:
user_configs:
- name: admin
token: admin-token
vault_access:
type: allow_access_to_all
```
**Use cases**:
- Administrator accounts
- Personal single-user deployments
- Development/testing
### Allow List
Grant access only to specific vaults:
```yaml
users:
user_configs:
- name: alice
token: alice-token
vault_access:
type: allow_list
allowed:
- personal
- shared-team
- project-alpha
```
**Use cases**:
- Multi-user deployments
- Restricted access scenarios
- Separation of concerns
**Notes**:
- User can only access listed vaults
- Attempting to access other vaults returns authentication error
- Empty list = no access to any vault
### Deny List
Grant access to all vaults except specific ones:
```yaml
users:
user_configs:
- name: bob
token: bob-token
vault_access:
type: deny_list
denied:
- restricted
- admin-only
```
**Use cases**:
- Users with broad access except sensitive vaults
- Simplify configuration when most vaults are accessible
**Notes**:
- User can access any vault not in the deny list
- Attempting to access denied vaults returns authentication error
## Multi-User Scenarios
### Personal Use (Single User)
```yaml
users:
user_configs:
- name: me
token: my-super-secret-token
vault_access:
type: allow_access_to_all
```
### Small Team (Shared Vaults)
```yaml
users:
user_configs:
- name: alice
token: alice-token
vault_access:
type: allow_list
allowed:
- personal-alice
- team-shared
- name: bob
token: bob-token
vault_access:
type: allow_list
allowed:
- personal-bob
- team-shared
- name: charlie
token: charlie-token
vault_access:
type: allow_list
allowed:
- personal-charlie
- team-shared
```
### Organization (Mixed Access)
```yaml
users:
user_configs:
- name: admin
token: admin-token
vault_access:
type: allow_access_to_all
- name: developer
token: dev-token
vault_access:
type: allow_list
allowed:
- engineering-docs
- api-specs
- shared
- name: designer
token: design-token
vault_access:
type: allow_list
allowed:
- design-docs
- brand-assets
- shared
- name: readonly
token: readonly-token
vault_access:
type: allow_list
allowed:
- public-wiki
```
## Authentication Flow
### Connection
1. Client connects via WebSocket
2. Client sends authentication message:
```json
{
"type": "auth",
"token": "user-token",
"vault": "vault-name"
}
```
3. Server validates:
- Token exists in config
- User has access to requested vault
4. Server responds:
- Success: Connection established
- Failure: Connection closed with error
### Validation
Server checks:
1. **Token match**: Token exists in `user_configs`
2. **Vault access**: User has permission for vault
3. **Connection limits**: Not exceeding `max_clients_per_vault`
### Errors
**Invalid token**:
```
Authentication failed: Invalid token
```
**No vault access**:
```
Authentication failed: User does not have access to vault 'restricted'
```
**Connection limit**:
```
Connection rejected: Maximum clients reached for vault
```
## Security Best Practices
### Token Generation
Generate strong tokens:
```bash
# 64 character hex token (256 bits)
openssl rand -hex 32
# Base64 encoded (256 bits)
openssl rand -base64 32
# UUID v4
uuidgen
```
### Token Storage
**In config file**:
```yaml
users:
user_configs:
- name: alice
token: !ENV ALICE_TOKEN # Read from environment variable
```
**Load from environment**:
```bash
export ALICE_TOKEN="$(openssl rand -hex 32)"
./sync_server config.yml
```
### Token Rotation
Periodically change tokens:
1. Generate new token
2. Update `config.yml`
3. Restart server
4. Update clients with new token
### Token Revocation
To revoke access:
1. Remove user from `config.yml`
2. Restart server
3. User's connections will be rejected
For immediate revocation:
- Remove user from config
- Restart server
- Existing connections are terminated
## Access Patterns
### Read-Only Users
VaultLink doesn't distinguish read-only vs read-write. Implement via client:
```yaml
# Server: Grant access
users:
user_configs:
- name: readonly
token: readonly-token
vault_access:
type: allow_list
allowed:
- public
# Client: Use CLI in read-only mode (mount vault read-only)
docker run -v /vault:/vault:ro ...
```
### Temporary Access
Grant temporary access:
1. Add user to config
2. Set reminder to remove later
3. Remove user when no longer needed
4. Restart server
For automation:
```bash
# Add user with expiry comment
echo " - name: temp-user # EXPIRES: 2024-12-31" >> config.yml
echo " token: temp-token" >> config.yml
```
### Shared Tokens (Not Recommended)
Multiple users sharing a token:
- All appear as same user in logs
- Can't revoke individual access
- Security risk if one person leaves
**Instead**: Create separate users with same vault access.
## Monitoring
### Server Logs
Authentication events are logged:
```
2024-01-01 12:00:00 INFO User 'alice' authenticated for vault 'personal'
2024-01-01 12:00:05 WARN Authentication failed: Invalid token from 192.168.1.100
2024-01-01 12:00:10 WARN User 'bob' denied access to vault 'restricted'
```
### Audit Trail
Monitor authentication:
```bash
# View authentication logs
grep "authenticated" logs/*.log
# View failed authentications
grep "Authentication failed" logs/*.log
# View access denials
grep "denied access" logs/*.log
```
## Advanced Scenarios
### Multiple Servers
Same user across multiple server instances:
```yaml
# Server 1 config.yml
users:
user_configs:
- name: alice
token: alice-global-token
vault_access:
type: allow_list
allowed:
- vault-1
- vault-2
# Server 2 config.yml
users:
user_configs:
- name: alice
token: alice-global-token # Same token
vault_access:
type: allow_list
allowed:
- vault-3
- vault-4
```
### Service Accounts
Tokens for automated systems:
```yaml
users:
user_configs:
- name: backup-service
token: backup-service-token
vault_access:
type: allow_access_to_all
- name: ci-pipeline
token: ci-token
vault_access:
type: allow_list
allowed:
- documentation
- name: monitoring
token: monitoring-token
vault_access:
type: allow_list
allowed:
- metrics
```
### Dynamic Vault Access
VaultLink doesn't support runtime user management. To change access:
1. Update `config.yml`
2. Restart server
3. Users reconnect automatically
For frequent changes, consider:
- Over-provision access (deny list)
- Use external authentication proxy
- Script config updates + reload
## Troubleshooting
### Can't connect
**Check token**:
```bash
# Verify token in config matches client
grep "token:" config.yml
```
**Check vault name**:
```bash
# Ensure vault is in allowed list
grep -A 5 "name: alice" config.yml
```
**Check server logs**:
```bash
tail -f logs/*.log | grep -i auth
```
### Access denied
**Verify vault access**:
```yaml
# Check user's vault_access configuration
users:
user_configs:
- name: alice
vault_access:
type: allow_list
allowed:
- vault-name # Must match exactly
```
**Case sensitivity**:
- Vault names are case-sensitive
- `Vault``vault`
- Ensure exact match in config and client
### Token not working
**Check for typos**:
- Extra spaces
- Hidden characters
- Wrong quotes in YAML
**Regenerate token**:
```bash
# Generate new token
openssl rand -hex 32
# Update config
# Restart server
# Update client
```
## Next Steps
- [Server configuration reference →](/config/server)
- [Advanced configuration →](/config/advanced)
- [Deploy the server →](/guide/server-setup)

489
docs/config/server.md Normal file
View file

@ -0,0 +1,489 @@
# Server Configuration
Complete reference for configuring the VaultLink sync server via `config.yml`.
## Configuration File Format
The server is configured using a YAML file passed as a command-line argument:
```bash
/app/sync_server /path/to/config.yml
```
## Complete Example
```yaml
database:
databases_directory_path: databases
max_connections_per_vault: 12
cursor_timeout_seconds: 60
server:
host: 0.0.0.0
port: 3000
max_body_size_mb: 512
max_clients_per_vault: 256
response_timeout_seconds: 60
users:
user_configs:
- name: admin
token: your-secure-random-token
vault_access:
type: allow_access_to_all
- name: alice
token: alice-token
vault_access:
type: allow_list
allowed:
- personal
- shared
- name: bob
token: bob-token
vault_access:
type: deny_list
denied:
- restricted
logging:
log_directory: logs
log_rotation: 7days
```
## Database Section
### `databases_directory_path`
**Type**: String
**Required**: Yes
**Default**: None
Directory where SQLite database files are stored. One database file per vault.
```yaml
database:
databases_directory_path: /data/databases
```
The directory structure:
```
databases/
├── vault-1.db
├── vault-2.db
└── personal.db
```
**Notes**:
- Path is relative to working directory or absolute
- Directory must be writable by the server process
- Ensure adequate disk space for vault data
- Back up this directory regularly
### `max_connections_per_vault`
**Type**: Integer
**Required**: Yes
**Default**: None
**Recommended**: 12
Maximum concurrent database connections per vault.
```yaml
database:
max_connections_per_vault: 12
```
**Tuning**:
- Higher values: Better performance under load
- Lower values: Less memory usage
- Typical range: 8-20
- Consider: Number of concurrent users × average operations per user
### `cursor_timeout_seconds`
**Type**: Integer
**Required**: Yes
**Default**: None
**Recommended**: 60
How long to keep database cursors alive for inactive clients.
```yaml
database:
cursor_timeout_seconds: 60
```
**Notes**:
- Cursors track client sync state
- Timeout too short: Clients may need to re-sync frequently
- Timeout too long: More memory usage
- Typical range: 30-300 seconds
## Server Section
### `host`
**Type**: String
**Required**: Yes
**Default**: None
Network interface to bind the server to.
```yaml
server:
host: 0.0.0.0 # All interfaces
# OR
host: 127.0.0.1 # Localhost only
# OR
host: 192.168.1.100 # Specific interface
```
**Common values**:
- `0.0.0.0`: Listen on all network interfaces (production)
- `127.0.0.1`: Listen on localhost only (development/testing)
- Specific IP: Listen on specific interface
### `port`
**Type**: Integer
**Required**: Yes
**Default**: None
**Recommended**: 3000
TCP port to listen on.
```yaml
server:
port: 3000
```
**Notes**:
- Must be available (not in use)
- Privileged ports (< 1024) require root
- Common ports: 3000, 8080, 8888
- Configure firewall to allow this port
### `max_body_size_mb`
**Type**: Integer
**Required**: Yes
**Default**: None
**Recommended**: 512
Maximum size of HTTP request body in megabytes.
```yaml
server:
max_body_size_mb: 512
```
**Usage**:
- Limits file upload size
- Prevents memory exhaustion attacks
- Must be larger than largest expected file
- Consider client `max_file_size_mb` settings
**Tuning**:
- Small vaults (mostly text): 100 MB
- Medium vaults (some images): 512 MB
- Large vaults (many images/PDFs): 1024+ MB
### `max_clients_per_vault`
**Type**: Integer
**Required**: Yes
**Default**: None
**Recommended**: 256
Maximum concurrent clients per vault.
```yaml
server:
max_clients_per_vault: 256
```
**Notes**:
- Limits concurrent WebSocket connections
- Prevents resource exhaustion
- Consider expected number of users
- Each client uses memory and file descriptors
**Scaling**:
- Personal use: 10-50
- Small team: 50-100
- Large team: 100-500
### `response_timeout_seconds`
**Type**: Integer
**Required**: Yes
**Default**: None
**Recommended**: 60
Maximum time to wait for client responses.
```yaml
server:
response_timeout_seconds: 60
```
**Usage**:
- Timeout for HTTP requests
- Timeout for WebSocket operations
- Clients disconnected if unresponsive
**Tuning**:
- Fast networks: 30 seconds
- Slow networks: 90-120 seconds
- Large file uploads: Increase proportionally
## Users Section
See [Authentication Configuration →](/config/authentication) for detailed user configuration.
## Logging Section
### `log_directory`
**Type**: String
**Required**: Yes
**Default**: None
Directory where log files are written.
```yaml
logging:
log_directory: /data/logs
# OR
log_directory: logs # Relative to working directory
```
**Notes**:
- Path is relative to working directory or absolute
- Directory must be writable
- Logs are rotated based on `log_rotation`
- Monitor disk usage
### `log_rotation`
**Type**: String
**Required**: Yes
**Default**: None
How often to rotate log files.
```yaml
logging:
log_rotation: 7days
# OR
log_rotation: 24hours
# OR
log_rotation: 30days
```
**Format**: `<number><unit>`
**Units**:
- `hours`: Hours (e.g., `12hours`, `24hours`)
- `days`: Days (e.g., `7days`, `30days`)
**Recommendations**:
- Development: `24hours` or `7days`
- Production: `7days` or `30days`
- High traffic: `24hours` (logs can be large)
## Environment-Specific Configurations
### Development
```yaml
database:
databases_directory_path: ./databases
max_connections_per_vault: 8
cursor_timeout_seconds: 30
server:
host: 127.0.0.1
port: 3000
max_body_size_mb: 100
max_clients_per_vault: 10
response_timeout_seconds: 30
users:
user_configs:
- name: dev
token: dev-token
vault_access:
type: allow_access_to_all
logging:
log_directory: logs
log_rotation: 24hours
```
### Production
```yaml
database:
databases_directory_path: /data/databases
max_connections_per_vault: 16
cursor_timeout_seconds: 120
server:
host: 0.0.0.0
port: 3000
max_body_size_mb: 512
max_clients_per_vault: 256
response_timeout_seconds: 90
users:
user_configs:
- name: admin
token: <strong-random-token>
vault_access:
type: allow_access_to_all
# Additional users...
logging:
log_directory: /data/logs
log_rotation: 7days
```
## Validation
The server validates configuration on startup:
```bash
# Start server
./sync_server config.yml
# Check for errors in logs
tail -f logs/latest.log
```
**Common errors**:
- Missing required fields
- Invalid YAML syntax
- Invalid values (negative numbers, etc.)
- Directory not writable
## Performance Tuning
### High Concurrency
For many concurrent users:
```yaml
database:
max_connections_per_vault: 20 # Increase
server:
max_clients_per_vault: 500 # Increase
response_timeout_seconds: 120 # Increase for slow clients
```
### Large Files
For vaults with large files:
```yaml
server:
max_body_size_mb: 1024 # Allow larger uploads
response_timeout_seconds: 180 # More time for uploads
```
### Resource-Constrained Systems
For limited CPU/memory:
```yaml
database:
max_connections_per_vault: 6 # Reduce
server:
max_clients_per_vault: 50 # Reduce
max_body_size_mb: 256 # Reduce
```
## Security Considerations
### Token Security
- Use strong random tokens: `openssl rand -hex 32`
- Never commit tokens to version control
- Rotate tokens periodically
- Use different tokens per user
### Network Security
- Bind to `127.0.0.1` if using reverse proxy on same host
- Use firewall to restrict access
- Enable SSL/TLS via reverse proxy
### Resource Limits
- Set `max_clients_per_vault` to prevent DoS
- Set `max_body_size_mb` to prevent memory exhaustion
- Configure `response_timeout_seconds` to prevent hanging connections
## Troubleshooting
### Server won't start
**Check YAML syntax**:
```bash
# Use a YAML validator
python -c 'import yaml, sys; yaml.safe_load(open("config.yml"))'
```
**Check file paths**:
```bash
# Ensure directories exist and are writable
mkdir -p databases logs
chmod 755 databases logs
```
**Check port availability**:
```bash
# Verify port is not in use
lsof -i :3000
```
### High memory usage
- Reduce `max_connections_per_vault`
- Reduce `max_clients_per_vault`
- Reduce `max_body_size_mb`
- Check for large vaults or many concurrent users
### Slow performance
- Increase `max_connections_per_vault`
- Increase database connection pool
- Use SSD for database storage
- Monitor database size (vacuum if needed)
## Next Steps
- [Configure authentication →](/config/authentication)
- [Advanced configuration options →](/config/advanced)
- [Deploy the server →](/guide/server-setup)

324
docs/guide/alternatives.md Normal file
View file

@ -0,0 +1,324 @@
# Comparison with Alternatives
VaultLink is one of several solutions for synchronising Obsidian vaults. This page compares VaultLink with popular alternatives to help you choose the right tool.
## Key Differentiator: Editor Agnostic
**VaultLink is not tied to Obsidian.** While it includes an Obsidian plugin for convenience, VaultLink synchronises plain text files and works with any editor:
- Edit with **Obsidian desktop** on your laptop
- Edit with **Vim** on your server
- Edit with **VS Code** on your workstation
- Edit with **Obsidian mobile** on your phone
- Use the **CLI client** for automated workflows
All changes merge automatically without conflict markers, regardless of which editor you use. This is possible because VaultLink uses [reconcile-text](/architecture/sync-algorithm#why-reconcile-text-over-crdts) for differential synchronisation rather than requiring operation-level tracking.
## VaultLink's Core Strengths
Before diving into comparisons:
1. **Fully self-hosted**: Server and all components are open source
2. **Collaborative editing**: Real-time sync with operational transformation
3. **Automatic conflict resolution**: No manual intervention or paid features required
4. **Cursor tracking**: See where other users are editing
5. **Extensively tested**: Comprehensive test suite for server and client
6. **Editor freedom**: Use any text editor, not just Obsidian
7. **Production-ready**: Docker images, health checks, monitoring
## Obsidian Sync Alternatives
### Self-hosted LiveSync
**Downloads**: ~300,000
**Repository**: https://github.com/vrtmrz/obsidian-livesync
**Overview**: CouchDB/IBM Cloudant-based sync with end-to-end encryption.
| Aspect | Self-hosted LiveSync | VaultLink |
| ------------------------- | --------------------------- | -------------------------------------- |
| **Self-hosted** | Yes (CouchDB required) | Yes (single binary or Docker) |
| **Conflict resolution** | Manual or automatic (basic) | Automatic (operational transformation) |
| **Collaborative editing** | No | Yes (real-time with cursors) |
| **Editor support** | Obsidian only | Any text editor |
| **Infrastructure** | CouchDB database | SQLite (bundled) |
| **Deployment complexity** | Medium (external DB) | Low (single container) |
| **End-to-end encryption** | Yes | No (transport encryption only) |
| **Out-of-band edits** | Limited support | Full support (edit with any tool) |
**When to use LiveSync**:
- Need end-to-end encryption
- Already running CouchDB
- Only use Obsidian (no external editors)
**When to use VaultLink**:
- Want collaborative editing with multiple users
- Edit files with various tools (Vim, VS Code, etc.)
- Need simpler deployment (no external database)
- Want operational transformation for better merges
---
### Remotely Save
**Downloads**: ~1.1M
**Repository**: https://github.com/remotely-save/remotely-save
**Overview**: Sync to cloud storage providers (S3, Dropbox, OneDrive, WebDAV).
| Aspect | Remotely Save | VaultLink |
| ------------------------- | ---------------------------- | ------------------------ |
| **Self-hosted** | Partial (uses cloud storage) | Fully self-hosted |
| **Conflict resolution** | Paid Pro feature | Free and automatic |
| **Collaborative editing** | No | Yes |
| **Editor support** | Obsidian only | Any text editor |
| **Storage backend** | Cloud providers | Self-hosted SQLite |
| **Cost** | Free (basic) / Paid (Pro) | Free (open source) |
| **Code quality** | No tests, complex codebase | Comprehensive test suite |
| **Real-time sync** | No (periodic polling) | Yes (WebSocket) |
**When to use Remotely Save**:
- Already use cloud storage (S3, Dropbox)
- Don't need real-time sync
- Single-user scenario
**When to use VaultLink**:
- Want full control over data
- Need automatic conflict resolution without paying
- Want real-time collaborative editing
- Value code quality and testing
**Note**: Remotely Save's conflict resolution is a paid feature. VaultLink provides superior automatic merging for free.
---
### Relay
**Downloads**: ~24,000
**Repository**: https://github.com/No-Instructions/Relay
**Overview**: CRDT-based sync with proprietary server component.
| Aspect | Relay | VaultLink |
| -------------------------- | ---------------------------- | ----------------------- |
| **Self-hosted** | No (proprietary server) | Yes (fully open source) |
| **Conflict resolution** | CRDT (automatic) | OT (automatic) |
| **Collaborative editing** | Yes | Yes |
| **Editor support** | Obsidian only | Any text editor |
| **Out-of-band edits** | No (breaks CRDT consistency) | Yes (differential sync) |
| **Server open source** | No | Yes |
| **Infrastructure control** | Limited | Full |
| **Per-file overhead** | High (CRDT metadata) | Low (version history) |
**When to use Relay**:
- Want hosted solution (don't self-host)
- Only edit within Obsidian
- Don't need out-of-band editing
**When to use VaultLink**:
- Need fully open source solution
- Want to self-host completely
- Edit files outside Obsidian (Vim, VS Code)
- Value infrastructure control
**Critical limitation**: Relay's CRDT approach requires tracking every operation within Obsidian. Editing files outside Obsidian breaks the CRDT state. VaultLink's differential sync works regardless of how files are edited.
---
### Obsidian Git
**Downloads**: ~1.4M
**Repository**: https://github.com/denolehov/obsidian-git
**Overview**: Uses Git for version control and synchronisation.
| Aspect | Obsidian Git | VaultLink |
| ------------------------- | ----------------------------- | ----------------------- |
| **Self-hosted** | Yes (Git server) | Yes (sync server) |
| **Conflict resolution** | Manual (conflict markers) | Automatic (no markers) |
| **Collaborative editing** | No | Yes (real-time) |
| **Editor support** | Any (it's Git) | Any (differential sync) |
| **Version history** | Full Git history | Document versions |
| **Real-time sync** | No (commit-based) | Yes (instant) |
| **Merge conflicts** | Manual resolution | Automatic |
| **Learning curve** | High (Git knowledge required) | Low |
| **Workflow interruption** | Yes (resolve conflicts) | No |
**When to use Obsidian Git**:
- Need full version control (branches, tags, etc.)
- Already familiar with Git workflows
- Want integration with existing Git repos
- Don't mind manual conflict resolution
**When to use VaultLink**:
- Want automatic conflict-free merging
- Need real-time collaborative editing
- Don't want workflow interruptions from merge conflicts
- Prefer simpler mental model (sync, not commits)
**Key difference**: Git requires manual conflict resolution with `<<<<<<<` markers. VaultLink automatically merges all changes using operational transformation, never interrupting your workflow.
---
### Syncthing Integration
**Downloads**: ~22,600
**Repository**: https://github.com/LBF38/obsidian-syncthing-integration
**Overview**: Wrapper around Syncthing for file synchronisation.
| Aspect | Syncthing Integration | VaultLink |
| ------------------------- | ------------------------------ | ----------------- |
| **Self-hosted** | Yes (Syncthing) | Yes (sync server) |
| **Conflict resolution** | Manual | Automatic |
| **Collaborative editing** | No | Yes |
| **Editor support** | Any | Any |
| **Status** | Unfinished | Production-ready |
| **Conflict files** | Creates `.sync-conflict` files | No conflict files |
| **Real-time sync** | Yes | Yes |
| **Automatic merging** | No | Yes |
**When to use Syncthing Integration**:
- Already use Syncthing for other files
- Don't need automatic conflict resolution
- Single-user with multiple devices
**When to use VaultLink**:
- Want automatic conflict resolution
- Need collaborative editing
- Want production-ready solution
- Don't want to manage conflict files
**Status note**: Syncthing Integration is marked as unfinished. VaultLink is production-ready with comprehensive testing.
---
### Remotely Sync
**Downloads**: ~38,000
**Repository**: https://github.com/sboesen/remotely-sync
**Overview**: Similar to Remotely Save, syncs to cloud storage.
| Aspect | Remotely Sync | VaultLink |
| ----------------------- | ----------------------- | ------------------- |
| **Self-hosted** | Partial (cloud storage) | Fully self-hosted |
| **Conflict resolution** | Limited/Paid | Free and automatic |
| **Code quality** | No tests | Comprehensive tests |
| **Maintenance** | Low activity | Active development |
**Same concerns as Remotely Save**: No test suite, conflict resolution limitations, cloud storage dependency.
**When to use VaultLink**: See Remotely Save comparison above.
---
### SyncFTP
**Downloads**: ~5,000
**Repository**: https://github.com/alex-donnan/SyncFTP
**Overview**: Simple FTP-based file synchronisation.
| Aspect | SyncFTP | VaultLink |
| ------------------------- | ---------------------- | ---------------- |
| **Conflict resolution** | None (last write wins) | Automatic (OT) |
| **Data loss risk** | High (overwrites) | None (merges) |
| **Collaborative editing** | No | Yes |
| **Sophistication** | Minimal | Production-grade |
**When to use SyncFTP**: Don't use SyncFTP for any scenario where data integrity matters.
**When to use VaultLink**: Any scenario requiring reliable synchronisation.
---
## Feature Comparison Matrix
| Feature | VaultLink | LiveSync | Relay | Git | Remotely Save | Syncthing |
| --------------------------------- | --------- | -------- | ----- | --- | ------------- | --------- |
| **Fully open source** | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ |
| **Self-hosted** | ✅ | ✅ | ❌ | ✅ | Partial | ✅ |
| **Automatic conflict resolution** | ✅ | Basic | ✅ | ❌ | Paid | ❌ |
| **Real-time sync** | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
| **Collaborative editing** | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| **Cursor tracking** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **Editor agnostic** | ✅ | ❌ | ❌ | ✅ | ❌ | ✅ |
| **Out-of-band edits** | ✅ | Limited | ❌ | ✅ | ❌ | ✅ |
| **No conflict markers** | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ |
| **Comprehensive tests** | ✅ | ❌ | ❌ | N/A | ❌ | N/A |
| **Simple deployment** | ✅ | ❌ | N/A | ❌ | ✅ | ❌ |
| **Low infrastructure** | ✅ | ❌ | N/A | ✅ | ✅ | ✅ |
---
## VaultLink's Unique Position
VaultLink is the **only** solution that combines:
1. **Fully open source** self-hosted server
2. **Editor agnostic** operation (not locked to Obsidian)
3. **Automatic conflict-free merging** using operational transformation
4. **Real-time collaborative editing** with cursor tracking
5. **Differential synchronisation** supporting out-of-band edits
6. **Comprehensive test coverage** ensuring reliability
7. **Simple deployment** via Docker or single binary
## Use Case Recommendations
### Choose VaultLink when you:
- Edit vaults with multiple editors (Obsidian + Vim + VS Code)
- Need real-time collaboration with teammates
- Want automatic conflict resolution without manual intervention
- Value full control over infrastructure
- Need production-ready reliability with comprehensive testing
- Want to edit files while offline and sync later seamlessly
### Consider alternatives when you:
- **LiveSync**: Need end-to-end encryption and only use Obsidian
- **Git**: Need full version control with branches and advanced Git features
- **Remotely Save**: Already committed to cloud storage providers
- **Syncthing**: Already use Syncthing and don't need automatic merging
## Migration from Other Solutions
VaultLink works with plain Markdown files, making migration simple:
1. **From Git**: Clone your repo, point VaultLink to the directory
2. **From cloud sync**: Download files, configure VaultLink client
3. **From LiveSync**: Export vault, import to VaultLink
4. **From Syncthing**: Point VaultLink to synced directory
All solutions work with the same Markdown files—VaultLink just syncs them better.
## Beyond Obsidian
Because VaultLink is editor-agnostic, you can use it for:
- **Documentation teams**: Sync technical docs edited in VS Code
- **Academic writing**: Collaborate on papers with various Markdown editors
- **Personal knowledge bases**: Use Obsidian on mobile, Vim on servers
- **Automated workflows**: CLI client for backup systems and CI/CD
- **Multi-tool workflows**: Different team members use different editors
VaultLink doesn't lock you into Obsidian—it's a general-purpose differential sync system that happens to work excellently with Obsidian vaults.
## Next Steps
Ready to try VaultLink?
- [Get started →](/guide/getting-started)
- [Understand the architecture →](/architecture/)
- [See how sync works →](/architecture/sync-algorithm)

532
docs/guide/cli-client.md Normal file
View file

@ -0,0 +1,532 @@
# CLI Client
Sync vaults without Obsidian. Works on servers, automation, backups, headless systems.
## Installation
### Docker (Recommended)
Pull the latest image:
```bash
docker pull ghcr.io/schmelczer/vault-link-cli:latest
```
### npm
Install globally:
```bash
npm install -g @schmelczer/local-client-cli
```
Verify installation:
```bash
vaultlink --version
```
### From Source
Build from the repository:
```bash
git clone https://github.com/schmelczer/vault-link.git
cd vault-link/frontend/local-client-cli
npm install
npm run build
node dist/cli.js --help
```
## Usage
### Basic Usage
```bash
vaultlink \
--local-path /path/to/vault \
--remote-uri wss://sync.example.com \
--token your-auth-token \
--vault-name default
```
### Docker Usage
```bash
docker run -v /path/to/vault:/vault \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault \
-r wss://sync.example.com \
-t your-auth-token \
-v default
```
### Docker Compose
Create `docker-compose.yml`:
```yaml
services:
vaultlink-cli:
image: ghcr.io/schmelczer/vault-link-cli:latest
restart: unless-stopped
volumes:
- ./vault:/vault
command:
- "-l"
- "/vault"
- "-r"
- "wss://sync.example.com"
- "-t"
- "your-token"
- "-v"
- "default"
```
Start the client:
```bash
docker compose up -d
```
## Configuration Options
### Required Arguments
| Argument | Short | Description | Example |
| -------------- | ----- | ----------------------- | ------------------------ |
| `--local-path` | `-l` | Local directory to sync | `/vault` |
| `--remote-uri` | `-r` | Server WebSocket URI | `wss://sync.example.com` |
| `--token` | `-t` | Authentication token | `abc123...` |
| `--vault-name` | `-v` | Vault name on server | `default` |
### Optional Arguments
| Argument | Default | Description |
| ------------------------------- | ------- | -------------------------------------- |
| `--sync-concurrency` | `1` | Concurrent file operations |
| `--max-file-size-mb` | `10` | Max file size in MB |
| `--ignore-pattern` | - | Glob pattern to ignore (repeatable) |
| `--websocket-retry-interval-ms` | `3500` | Reconnection interval |
| `--log-level` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
### Environment Variables
Alternative to command-line arguments:
```bash
export VAULTLINK_LOCAL_PATH="/vault"
export VAULTLINK_REMOTE_URI="wss://sync.example.com"
export VAULTLINK_TOKEN="your-token"
export VAULTLINK_VAULT_NAME="default"
vaultlink
```
## Examples
### Basic Sync
Sync a local directory to the server:
```bash
vaultlink \
-l ./my-notes \
-r wss://sync.example.com \
-t my-secure-token \
-v personal
```
### With Ignore Patterns
Exclude specific files or directories:
```bash
vaultlink \
-l ./vault \
-r wss://sync.example.com \
-t token123 \
-v default \
--ignore-pattern "*.tmp" \
--ignore-pattern ".DS_Store" \
--ignore-pattern "node_modules/**"
```
### Debug Logging
Enable verbose logging:
```bash
vaultlink \
-l ./vault \
-r wss://sync.example.com \
-t token123 \
-v default \
--log-level DEBUG
```
### High Concurrency
Faster initial sync:
```bash
vaultlink \
-l ./vault \
-r wss://sync.example.com \
-t token123 \
-v default \
--sync-concurrency 5
```
### Large Files
Allow larger file uploads:
```bash
vaultlink \
-l ./vault \
-r wss://sync.example.com \
-t token123 \
-v default \
--max-file-size-mb 50
```
## Docker Deployment
### Long-Running Sync
Run as a daemon for continuous synchronisation:
```bash
docker run -d \
--name vaultlink-sync \
--restart unless-stopped \
-v $(pwd)/vault:/vault \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault \
-r wss://sync.example.com \
-t your-token \
-v default
```
Monitor logs:
```bash
docker logs -f vaultlink-sync
```
### Health Monitoring
The Docker image includes built-in health checks:
```bash
# Check health status
docker ps
# View detailed health info
docker inspect --format='{{json .State.Health}}' vaultlink-sync | jq
```
Health check verifies:
- Health file exists
- Status updated within last 30 seconds
- WebSocket connection is active
Configure custom health check:
```yaml
services:
vaultlink-cli:
image: ghcr.io/schmelczer/vault-link-cli:latest
healthcheck:
test: ["CMD", "node", "/app/healthcheck.js"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
```
### Read-Only Vault
Mount vault as read-only to prevent local changes:
```bash
docker run -d \
-v $(pwd)/vault:/vault:ro \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault \
-r wss://sync.example.com \
-t token \
-v default
```
::: warning
The CLI needs write access to create `.vaultlink` metadata directory. Mount as read-write or provide a separate writeable directory.
:::
## How It Works
### Initial Sync
On startup:
1. Creates `.vaultlink/` directory for metadata
2. Scans local filesystem
3. Uploads all local files to server
4. Downloads files from server not present locally
5. Resolves conflicts using operational transformation
### Real-Time Synchronization
After initial sync:
1. Watches filesystem for changes using `fs.watch`
2. Uploads changed files immediately
3. Receives real-time updates from server via WebSocket
4. Handles bidirectional sync automatically
### Graceful Shutdown
On SIGINT (Ctrl+C) or SIGTERM:
1. Completes pending uploads
2. Closes WebSocket connection cleanly
3. Flushes metadata to disk
4. Exits gracefully
## Use Cases
### Automated Backups
Continuously backup vaults to a remote server:
```bash
docker run -d \
--name vault-backup \
-v /important/notes:/vault:ro \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault -r wss://backup.example.com -t backup-token -v backups
```
### CI/CD Documentation
Sync documentation in automated pipelines:
```bash
# In your CI pipeline
docker run \
-v $(pwd)/docs:/vault \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault -r wss://docs.example.com -t ci-token -v prod-docs
```
### Multi-Location Sync
Sync between different geographic locations:
```bash
# Location A
vaultlink -l /data/vault -r wss://hub.example.com -t token -v shared
# Location B
vaultlink -l /backup/vault -r wss://hub.example.com -t token -v shared
```
### Development Environment
Keep documentation in sync across dev environments:
```bash
# In docker-compose.yml for your dev stack
services:
docs-sync:
image: ghcr.io/schmelczer/vault-link-cli:latest
volumes:
- ./docs:/vault
command: ["-l", "/vault", "-r", "wss://docs-server", "-t", "dev-token", "-v", "dev"]
```
## Troubleshooting
### Client won't connect
**Check server accessibility**:
```bash
curl https://sync.example.com/vaults/test/ping
```
**Verify WebSocket protocol**:
- Use `ws://` for HTTP servers
- Use `wss://` for HTTPS servers
**Check authentication**:
- Token must match server config
- User must have access to the vault
### Permission errors
**Docker volume permissions**:
```bash
# Ensure directory is writable
chmod 755 /path/to/vault
# Check Docker user ID
docker run --rm ghcr.io/schmelczer/vault-link-cli:latest id
```
**SELinux issues**:
```bash
# Add :z flag to volume mount
docker run -v /path/to/vault:/vault:z ...
```
### Files not syncing
**Check ignore patterns**:
- View logs to see which files are skipped
- Ensure patterns don't match unintentionally
**File size limits**:
- Check `--max-file-size-mb` setting
- Large files are skipped with a warning
**Check metadata**:
```bash
# View sync metadata
cat /path/to/vault/.vaultlink/metadata.json
```
### High memory usage
**Reduce concurrency**:
```bash
--sync-concurrency 1
```
**Limit file sizes**:
```bash
--max-file-size-mb 5
```
**Check vault size**:
- Very large vaults may need more resources
- Consider splitting into multiple vaults
### Connection keeps dropping
**Increase retry interval**:
```bash
--websocket-retry-interval-ms 5000
```
**Check network stability**:
```bash
# Monitor connection
docker logs -f vaultlink-sync | grep -i websocket
```
**Server timeout settings**:
- Verify reverse proxy WebSocket timeout
- Check server `response_timeout_seconds`
## Advanced Usage
### Custom Healthcheck Script
Create your own health monitoring:
```bash
#!/bin/bash
HEALTH_FILE="/tmp/vaultlink-health.json"
if [ ! -f "$HEALTH_FILE" ]; then
exit 1
fi
# Check file is recent (within 60 seconds)
if [ $(( $(date +%s) - $(stat -c %Y "$HEALTH_FILE") )) -gt 60 ]; then
exit 1
fi
# Check WebSocket is connected
if ! jq -e '.connected == true' "$HEALTH_FILE" > /dev/null; then
exit 1
fi
exit 0
```
### Automated Recovery
Restart on failure with exponential backoff:
```bash
#!/bin/bash
RETRY_DELAY=5
while true; do
vaultlink -l /vault -r wss://server -t token -v default
echo "Client exited, restarting in ${RETRY_DELAY}s..."
sleep $RETRY_DELAY
# Exponential backoff up to 5 minutes
RETRY_DELAY=$((RETRY_DELAY * 2))
if [ $RETRY_DELAY -gt 300 ]; then
RETRY_DELAY=300
fi
done
```
### Integration with systemd
Create `/etc/systemd/system/vaultlink-cli.service`:
```ini
[Unit]
Description=VaultLink CLI Sync
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Restart=always
RestartSec=10
Environment="VAULTLINK_LOCAL_PATH=/data/vault"
Environment="VAULTLINK_REMOTE_URI=wss://sync.example.com"
Environment="VAULTLINK_TOKEN=your-token"
Environment="VAULTLINK_VAULT_NAME=default"
ExecStart=/usr/local/bin/vaultlink
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable vaultlink-cli
sudo systemctl start vaultlink-cli
```
## Next Steps
- [Configure server authentication →](/config/authentication)
- [Learn about the sync algorithm →](/architecture/sync-algorithm)
- [Set up Obsidian plugin →](/guide/obsidian-plugin)

View file

@ -0,0 +1,125 @@
# Getting Started
Set up VaultLink in 5 minutes. Deploy server, connect clients, done.
## Prerequisites
- Docker (or Rust toolchain if building from source)
- A server (VPS, home server, or localhost for testing)
## Step 1: Deploy Server
Create `config.yml`:
```yaml
database:
databases_directory_path: databases
max_connections_per_vault: 12
cursor_timeout_seconds: 60
server:
host: 0.0.0.0
port: 3000
max_body_size_mb: 512
max_clients_per_vault: 256
response_timeout_seconds: 60
users:
user_configs:
- name: admin
token: change-this-to-secure-random-token
vault_access:
type: allow_access_to_all
logging:
log_directory: logs
log_rotation: 7days
```
::: tip
Generate secure token: `openssl rand -hex 32`
:::
Run server:
```bash
docker run -d \
--name vaultlink-server \
--restart unless-stopped \
-p 3000:3000 \
-v $(pwd):/data \
ghcr.io/schmelczer/vault-link-server:latest \
/app/sync_server /data/config.yml
```
Verify: `curl http://localhost:3000/vaults/test/ping` should return server version and auth status
## Step 2: Connect Client
### Obsidian Plugin
1. Settings → Community Plugins → Browse
2. Search "VaultLink", install, enable
3. Configure:
- Server URL: `ws://localhost:3000` (or `wss://your-server.com` for SSL)
- Token: Your token from config.yml
- Vault Name: `default`
[Full plugin guide →](/guide/obsidian-plugin)
### CLI Client
```bash
docker run -d \
--name vaultlink-cli \
--restart unless-stopped \
-v /path/to/vault:/vault \
ghcr.io/schmelczer/vault-link-cli:latest \
-l /vault -r ws://localhost:3000 -t your-token -v default
```
[Full CLI guide →](/guide/cli-client)
## Production Setup
For production:
1. **SSL/TLS**: Use Nginx/Caddy reverse proxy for `wss://` ([setup guide](/guide/server-setup#ssl-tls-with-reverse-proxy))
2. **Secure tokens**: Generate with `openssl rand -hex 32`, don't reuse the example
3. **Firewall**: Only expose port 3000 to reverse proxy
4. **Backups**: SQLite databases are in `databases/` directory
## Multiple Users
```yaml
users:
user_configs:
- name: alice
token: alice-token
vault_access:
type: allow_list
allowed:
- personal
- shared
- name: bob
token: bob-token
vault_access:
type: allow_list
allowed:
- shared
```
[Auth docs →](/config/authentication)
## Troubleshooting
**Server won't start**: `docker logs vaultlink-server`
**Client can't connect**:
1. Verify server: `curl http://your-server:3000/vaults/test/ping`
2. Check URL: `ws://` for HTTP, `wss://` for HTTPS
3. Verify token matches config.yml
**Understanding limitations**: [See what VaultLink can and can't do →](/guide/limitations)
**Files not syncing**: Check client logs, verify vault name matches
[Server setup →](/guide/server-setup) | [Architecture →](/architecture/)

192
docs/guide/limitations.md Normal file
View file

@ -0,0 +1,192 @@
# Limitations
VaultLink works well for most Obsidian vaults, but has some constraints you should know about.
## File Type Limitations
### Mergeable Files
Only **`.md`** and **`.txt`** files get automatic conflict-free merging.
Other file types (images, PDFs, etc.) use last-write-wins:
```
User A updates diagram.png → Server stores version 1
User B updates diagram.png → Server stores version 2 (overwrites A's changes)
```
**Workaround**: Avoid editing the same non-text file simultaneously.
### Binary Detection
Files are treated as binary if they:
- Contain NUL bytes (`0x00`)
- Fail UTF-8 validation
Binary files within `.md` or `.txt` extensions still get last-write-wins (no merge).
## Performance Constraints
### Server Limits (Configurable)
| Resource | Default | Maximum Tested |
| ------------------------ | ------- | -------------- |
| Clients per vault | 256 | ~256 |
| Database connections | 12 | 20 |
| Max file size | 512 MB | 4096 MB |
| Request timeout | 60s | 180s |
| WebSocket cursor timeout | 60s | 300s |
| Database busy timeout | 3600s | - |
### Vault Size
- **Small vaults** (< 1000 files): Excellent performance
- **Medium vaults** (1000-10000 files): Good performance
- **Large vaults** (> 10000 files): Works, but initial sync slower
No hard file count limit—constrained by disk space and sync time.
### Resource Usage
Rough estimates (varies by vault size and activity):
- **RAM**: ~50-200 MB base + ~1-5 MB per active client
- **CPU**: Low (< 5%) for typical usage, spikes during merges
- **Disk**: Vault size + version history (grows over time)
## Version History
### Storage
- All versions stored indefinitely (no automatic cleanup)
- Each vault is a separate SQLite database
- Deleted files marked as deleted (not purged)
**Growth**: Version history grows with every change. A 10 MB vault with frequent edits might grow to 100+ MB over months.
**Cleanup**: Manual only (see [Advanced Configuration](/config/advanced#version-history-cleanup)).
### Implications
- Disk usage grows over time
- Database size affects backup time
- No built-in retention policy
## Merge Quality
### Text Merging
VaultLink uses word-level tokenisation for merging:
```markdown
Parent: "The quick brown fox"
User A: "The quick red fox"
User B: "The very quick brown fox"
Result: "The very quick red fox" ← Both changes preserved
```
**Imperfect scenarios**:
- Complex nested Markdown (tables, code blocks)
- Simultaneous edits to the same sentence
- Large structural changes (moving sections around)
**Result**: Merged file might need manual cleanup in ~1-5% of concurrent edits.
## Scalability
### SQLite Limitations
- One SQLite database per vault
- Single-server architecture (no built-in clustering)
- Write serialisation through database
**For high concurrency**: Consider multiple vaults instead of one massive shared vault.
### Horizontal Scaling
Not currently supported. Running multiple servers requires manual vault partitioning.
## Network Requirements
### Latency
- Real-time sync typically < 500ms on good connections
- Mobile/slow networks: 1-5s latency possible
- Timeout failures on very slow connections (> 60s)
### Offline Behaviour
- Clients queue changes locally
- On reconnect, sync all changes since last connection
- Conflicts resolved automatically (for mergeable files)
**Limitation**: No offline conflict preview—merged result appears after reconnect.
## Security
### No End-to-End Encryption
- Server sees all file contents
- Transport encryption only (WSS/TLS)
- Trust your server
**Workaround**: Self-host on infrastructure you control.
### Authentication
- Token-based only (no OAuth, SAML, etc.)
- Tokens configured in server config file
- No runtime user management
## Known Edge Cases
### Simultaneous Deletes and Edits
```
User A deletes note.md
User B edits note.md
Result: Edit wins (file recreated with B's content)
```
Operational transformation prioritises content preservation.
### Large File Uploads
Files > 100 MB may time out on slow connections. Increase `response_timeout_seconds` or split large files.
### Mobile Sync
- Mobile networks may drop WebSocket connections frequently
- Client auto-reconnects, but causes sync delays
- Battery impact from constant reconnections
## What VaultLink is NOT
- **Not a backup solution**: Version history helps but isn't a backup (make backups!)
- **Not Git**: No branching, no commit messages, no diffs to review before merge
- **Not encrypted storage**: Server sees everything
- **Not multi-master**: One server, multiple clients (not peer-to-peer)
## Recommendations
### Good Use Cases
- Personal multi-device sync (< 10 devices)
- Small team collaboration (< 20 people)
- Primarily text/Markdown content
- Trusted server environment
### Poor Use Cases
- Large teams (> 50 concurrent users per vault)
- Primarily binary files (images, videos, large PDFs)
- Untrusted server (need E2E encryption)
- Highly regulated environments (HIPAA, etc.)
## Next Steps
- [Server configuration limits →](/config/server)
- [Advanced tuning →](/config/advanced)
- [Architecture details →](/architecture/)

View file

@ -0,0 +1,276 @@
# Obsidian Plugin
Real-time sync for Obsidian vaults.
## Installation
### From Obsidian Community Plugins
1. Open Obsidian Settings
2. Navigate to **Community Plugins**
3. Click **Browse** and search for "VaultLink"
4. Click **Install**
5. Enable the plugin
### Manual Installation
1. Download the latest release from [GitHub Releases](https://github.com/schmelczer/vault-link/releases)
2. Extract `main.js`, `manifest.json`, and `styles.css`
3. Copy to `.obsidian/plugins/vault-link/` in your vault
4. Reload Obsidian
5. Enable VaultLink in Community Plugins settings
## Configuration
After installation, configure the plugin in **Settings → VaultLink**.
### Required Settings
#### Server URL
The WebSocket URL of your sync server.
- **Development/Local**: `ws://localhost:3000`
- **Production (SSL)**: `wss://sync.example.com`
::: tip
Use `ws://` for unencrypted connections and `wss://` for SSL connections (production).
:::
#### Authentication Token
Your authentication token from the server's `config.yml`.
Generate a secure token:
```bash
openssl rand -hex 32
```
#### Vault Name
The name of the vault on the server. Can be any string.
Multiple Obsidian vaults can sync to the same server vault name (for shared vaults), or use unique names for separate vaults.
### Optional Settings
#### Sync Concurrency
Number of files to sync simultaneously.
- **Default**: 1
- **Range**: 1-10
- Higher values = faster initial sync, more resource usage
#### Max File Size
Maximum file size to sync (in MB).
- **Default**: 10
- Files larger than this are skipped
#### Ignore Patterns
Glob patterns for files to exclude from sync.
Examples:
- `*.tmp` - Ignore temporary files
- `.trash/**` - Ignore trash folder
- `private/**` - Ignore private directory
#### WebSocket Retry Interval
Milliseconds between reconnection attempts when disconnected.
- **Default**: 3500ms
- Increase for flaky networks to avoid connection spam
## Usage
### Initial Sync
When first connecting:
1. The plugin uploads all local files to the server
2. Downloads any missing files from the server
3. Resolves any conflicts using operational transformation
4. Begins real-time synchronisation
Initial sync time depends on vault size and `sync_concurrency` setting.
### Real-Time Sync
Once connected:
- **File changes**: Automatically synced when saved
- **File creation**: New files immediately uploaded
- **File deletion**: Deletions propagated to other clients
- **File renames**: Tracked and synchronised
The plugin watches your vault filesystem and syncs changes in real-time via WebSocket.
### Status Indicators
The plugin provides visual feedback:
- **Connected**: Green status in settings
- **Syncing**: Progress indicator during uploads
- **Disconnected**: Red status, automatic reconnection attempts
- **Error**: Error message in settings and console
Check the Obsidian console (Ctrl+Shift+I / Cmd+Option+I) for detailed logs.
## Features
### Automatic Conflict Resolution
When multiple users edit the same file simultaneously, operational transformation merges changes automatically:
- All edits are preserved
- No manual conflict resolution required
- Changes appear in real-time as others type
### Mobile Support
VaultLink works on Obsidian mobile (iOS and Android):
- Same configuration as desktop
- Real-time sync across all devices
- Handle network changes gracefully
::: warning
Ensure your sync server is accessible from mobile networks (use WSS with a public domain or VPN).
:::
### Offline Support
The plugin handles offline scenarios:
- Continue working when disconnected
- Changes queue locally
- Automatic sync when connection restored
- Conflict resolution if others edited the same files
## Collaboration Workflows
### Personal Multi-Device Sync
Sync the same vault across devices:
1. Configure each Obsidian instance with the same vault name
2. Use the same authentication token
3. All devices stay in sync automatically
### Team Shared Vault
Multiple users collaborating:
1. Each user has their own token (configured in server `config.yml`)
2. All users connect to the same vault name
3. Real-time collaborative editing with automatic conflict resolution
### Selective Sharing
Share specific folders while keeping others private:
1. Use different vault names for shared vs. private content
2. Configure access control on the server per vault
3. Use ignore patterns to exclude sensitive directories
## Troubleshooting
### Plugin won't connect
1. **Verify server is running**:
```bash
curl http://your-server:3000/vaults/test/ping
```
Should return `pong`
2. **Check URL format**:
- Local: `ws://localhost:3000`
- Remote (SSL): `wss://sync.example.com`
- Don't include `/vault/name` in the URL
3. **Verify token**:
- Must match server config exactly
- No extra spaces or quotes
- Check server logs for authentication errors
4. **Check firewall**:
- Ensure port is accessible from your network
- For mobile, server must be publicly accessible or use VPN
### Files not syncing
1. **Check ignore patterns**: File may match an exclusion pattern
2. **File size**: Check if file exceeds `max_file_size_mb`
3. **Permissions**: Ensure vault directory is readable/writable
4. **Console errors**: Open dev tools (Ctrl+Shift+I) and check console
### Slow initial sync
1. **Increase concurrency**: Set `sync_concurrency` higher (e.g., 5)
2. **Network speed**: Check internet connection
3. **Server resources**: Ensure server isn't overloaded
4. **Large files**: Consider increasing timeout settings
### Conflicts not resolving
Operational transformation should handle conflicts automatically. If issues persist:
1. Check console for sync errors
2. Verify both clients are connected
3. Check server logs for processing errors
4. Ensure files are text-based (binary files may not merge well)
### High CPU/Memory usage
1. **Reduce concurrency**: Lower `sync_concurrency`
2. **Add ignore patterns**: Exclude unnecessary files
3. **File watchers**: Large vaults may trigger many filesystem events
4. **Check for sync loops**: Ensure no circular dependencies
## Advanced Configuration
### Multiple Vaults
To sync multiple Obsidian vaults to different server vaults:
1. Each Obsidian vault has its own VaultLink plugin configuration
2. Use different vault names for each
3. Can use the same or different tokens (depending on access control)
### Custom Sync Patterns
Combine ignore patterns for fine-grained control:
```
# Ignore patterns
*.tmp
*.bak
.DS_Store
.trash/**
private/**
drafts/**/*.draft.md
```
### Development/Testing
For plugin development:
1. Clone the repository
2. `cd frontend && npm install`
3. `npm run dev` to build in watch mode
4. Plugin rebuilds automatically on changes
5. Reload Obsidian to test changes
## Next Steps
- [Learn about the sync algorithm →](/architecture/sync-algorithm)
- [Configure the server →](/config/server)
- [Set up the CLI client →](/guide/cli-client)

379
docs/guide/server-setup.md Normal file
View file

@ -0,0 +1,379 @@
# Server Setup
Deploy VaultLink server via Docker, binary, or build from source.
## Deployment Options
### Docker (Recommended)
Easiest deployment path, includes health checks.
#### Basic Docker Deployment
```bash
# Pull the latest image
docker pull ghcr.io/schmelczer/vault-link-server:latest
# Create data directory
mkdir -p ~/vaultlink-data
# Create config.yml (see Configuration section below)
# Run the container
docker run -d \
--name vaultlink-server \
--restart unless-stopped \
-p 3000:3000 \
-v ~/vaultlink-data:/data \
ghcr.io/schmelczer/vault-link-server:latest \
/app/sync_server /data/config.yml
```
#### Docker Compose
Create `docker-compose.yml`:
```yaml
services:
vaultlink-server:
image: ghcr.io/schmelczer/vault-link-server:latest
container_name: vaultlink-server
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- ./data:/data
command: ["/app/sync_server", "/data/config.yml"]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/vaults/fake/ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
```
Start the server:
```bash
docker compose up -d
```
### Binary Installation
Download pre-built binaries from [GitHub Releases](https://github.com/schmelczer/vault-link/releases):
```bash
# Download the binary for your platform
wget https://github.com/schmelczer/vault-link/releases/latest/download/sync_server-linux-x86_64
# Make executable
chmod +x sync_server-linux-x86_64
# Run the server
./sync_server-linux-x86_64 config.yml
```
### Build from Source
Requirements: Rust 1.89.0+, SQLite development headers, SQLx CLI
```bash
# Clone the repository
git clone https://github.com/schmelczer/vault-link.git
cd vault-link/sync-server
# Install SQLx CLI
cargo install sqlx-cli
# Set up the database
sqlx database create --database-url sqlite://db.sqlite3
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
cargo sqlx prepare --workspace
# Build in release mode
cargo build --release
# Run the server
./target/release/sync_server config.yml
```
## Configuration
Create a `config.yml` file with your server configuration:
```yaml
database:
databases_directory_path: databases
max_connections_per_vault: 12
cursor_timeout_seconds: 60
server:
host: 0.0.0.0
port: 3000
max_body_size_mb: 512
max_clients_per_vault: 256
response_timeout_seconds: 60
users:
user_configs:
- name: admin
token: your-secure-random-token-here
vault_access:
type: allow_access_to_all
logging:
log_directory: logs
log_rotation: 7days
```
### Configuration Fields
#### Database
- `databases_directory_path`: Directory for SQLite databases (one per vault)
- `max_connections_per_vault`: Maximum concurrent database connections
- `cursor_timeout_seconds`: How long to keep database cursors alive
#### Server
- `host`: Bind address (use `0.0.0.0` for all interfaces)
- `port`: Port to listen on (default: 3000)
- `max_body_size_mb`: Maximum upload size
- `max_clients_per_vault`: Concurrent client limit per vault
- `response_timeout_seconds`: Request timeout
#### Users
See [Authentication Configuration →](/config/authentication) for detailed user setup.
#### Logging
- `log_directory`: Where to store log files
- `log_rotation`: How often to rotate logs (e.g., `7days`, `24hours`)
## Production Deployment
### SSL/TLS with Reverse Proxy
VaultLink doesn't handle SSL directly. Use a reverse proxy like Nginx or Caddy.
#### Nginx Configuration
```nginx
upstream vaultlink {
server localhost:3000;
}
server {
listen 443 ssl http2;
server_name sync.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://vaultlink;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket specific
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
```
Reload Nginx:
```bash
sudo nginx -t
sudo systemctl reload nginx
```
#### Caddy Configuration
Caddy handles SSL automatically:
```caddy
sync.example.com {
reverse_proxy localhost:3000
}
```
Start Caddy:
```bash
caddy run --config Caddyfile
```
### Systemd Service
Create `/etc/systemd/system/vaultlink.service`:
```ini
[Unit]
Description=VaultLink Sync Server
After=network.target
[Service]
Type=simple
User=vaultlink
WorkingDirectory=/opt/vaultlink
ExecStart=/opt/vaultlink/sync_server /opt/vaultlink/config.yml
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable vaultlink
sudo systemctl start vaultlink
sudo systemctl status vaultlink
```
### Security Best Practices
1. **Use strong tokens**: Generate with `openssl rand -hex 32`
2. **Enable firewall**: Only expose port 3000 to reverse proxy
3. **Regular updates**: Keep Docker images and binaries updated
4. **Backup databases**: SQLite files in `databases_directory_path`
5. **Monitor logs**: Check log directory for errors and anomalies
6. **Limit access**: Use vault-specific access controls per user
### Backup Strategy
The SQLite databases contain all vault data and history:
```bash
# Backup script
#!/bin/bash
BACKUP_DIR="/backup/vaultlink/$(date +%Y%m%d)"
DATA_DIR="/data/databases"
mkdir -p "$BACKUP_DIR"
cp -r "$DATA_DIR" "$BACKUP_DIR/"
# Keep 30 days of backups
find /backup/vaultlink -type d -mtime +30 -exec rm -rf {} +
```
Run daily via cron:
```cron
0 2 * * * /opt/vaultlink/backup.sh
```
### Monitoring
#### Health Checks
The server exposes a ping endpoint:
```bash
curl http://localhost:3000/vaults/test/ping
# Returns: {"server_version":"0.10.1","is_authenticated":false}
```
Replace `test` with any vault name. The endpoint returns:
- `server_version`: Current server version
- `is_authenticated`: Whether the request included a valid token
Docker health check is built-in and checks this endpoint every 30 seconds.
#### Prometheus Metrics
For advanced monitoring, collect Docker stats or implement custom metrics.
#### Log Monitoring
Logs are written to the configured `log_directory`. Monitor for:
- Connection failures
- Authentication errors
- Database errors
- WebSocket disconnections
Example log watching:
```bash
tail -f /data/logs/*.log | grep -i error
```
## Scaling
### Horizontal Scaling
VaultLink currently uses SQLite, which limits horizontal scaling. For multiple servers:
1. Run separate instances for different vaults
2. Use load balancer with sticky sessions (same vault → same server)
3. Consider database architecture for your scale needs
### Vertical Scaling
Increase resources for the server:
- More CPU for handling concurrent connections
- More RAM for database caching
- Faster storage (SSD) for database operations
Tune configuration:
- Increase `max_clients_per_vault` for more concurrent users
- Increase `max_connections_per_vault` for database performance
- Adjust `max_body_size_mb` based on typical file sizes
## Troubleshooting
### Server won't start
```bash
# Check Docker logs
docker logs vaultlink-server
# Common issues:
# - Port already in use: Change port mapping
# - Config syntax error: Validate YAML
# - Permission error: Check volume permissions
```
### High memory usage
- Reduce `max_connections_per_vault`
- Reduce `max_clients_per_vault`
- Check for large vaults (may need database optimisation)
### Database corruption
```bash
# Verify database integrity
sqlite3 databases/your-vault.db "PRAGMA integrity_check;"
# If corrupted, restore from backup
cp /backup/databases/your-vault.db /data/databases/
```
### WebSocket connection drops
- Check reverse proxy timeout settings
- Verify firewall isn't closing connections
- Review client retry intervals
- Check server logs for errors
## Next Steps
- [Configure authentication and access control →](/config/authentication)
- [Set up Obsidian plugin →](/guide/obsidian-plugin)
- [Deploy CLI client →](/guide/cli-client)
- [Understand the architecture →](/architecture/)

View file

@ -0,0 +1,71 @@
# What is VaultLink?
Self-hosted sync for Obsidian vaults with automatic conflict-free merging. Edit with any tool, collaborate in real-time, no conflict markers.
## The Problem
Syncing Obsidian vaults across devices or sharing with teammates sucks:
- **Commercial services**: Lock-in, subscriptions, third-party access to your data
- **Git**: Manual conflict resolution with `<<<<<<<` markers interrupting your workflow
- **Cloud storage**: Last-write-wins data loss or manual conflict resolution
- **CRDT solutions**: Only work if you edit inside Obsidian (break if you use Vim, VS Code, etc.)
## VaultLink's Solution
Differential synchronisation with operational transformation for Markdown and text files.
Edit `.md` and `.txt` files with Obsidian, Vim, VS Code, or any editor. VaultLink compares versions and automatically merges all changes. No operation tracking required, no conflict markers.
**Note**: Binary files (images, PDFs, etc.) use last-write-wins. [See limitations →](/guide/limitations)
## How It Works
1. **Server**: Rust WebSocket server with SQLite stores document versions
2. **Clients**: Obsidian plugin or CLI client watches filesystem changes
3. **Sync**: Changes upload to server, server broadcasts to other clients
4. **Merge**: [reconcile-text](https://schmelczer.dev/reconcile) automatically merges concurrent edits
No CRDT infrastructure. No operation logs. Just file comparison and smart merging.
## Key Advantages
**Editor agnostic**: Edit files with any tool. Other solutions break when you edit outside their ecosystem.
**Self-hosted**: Your data, your server. No third parties, no subscriptions, no surprises.
**Automatic merging**: Operational transformation handles conflicts without interrupting your workflow.
**Production-ready**: Comprehensive tests, E2E tests, battle-tested. Many alternatives have zero tests.
**Collaborative**: Real-time sync with cursor tracking. See where teammates are editing.
## Not Tied to Obsidian
VaultLink syncs Markdown files. Use it for:
- Obsidian vaults (Obsidian desktop + mobile + CLI)
- Technical documentation (VS Code, your-editor, CLI)
- Academic writing (multiple Markdown editors)
- Automated workflows (CLI client for backups/CI/CD)
The Obsidian plugin is just a convenience wrapper around the sync client.
## Quick Comparison
| Feature | VaultLink | Git | Cloud Sync | CRDT Solutions |
| ------------------- | --------- | --- | ---------- | -------------- |
| Self-hosted | ✅ | ✅ | ❌ | Varies |
| Any editor | ✅ | ✅ | ✅ | ❌ |
| No conflict markers | ✅ | ❌ | ❌ | ✅ |
| Real-time | ✅ | ❌ | ❌ | ✅ |
| No subscriptions | ✅ | ✅ | ❌ | Varies |
| Comprehensive tests | ✅ | N/A | N/A | ❌ |
[Detailed comparison with alternatives →](/guide/alternatives)
## Next Steps
- [Get started →](/guide/getting-started) (5 minute setup)
- [See the architecture →](/architecture/) (understand how it works)
- [Compare alternatives →](/guide/alternatives) (why VaultLink vs others)

55
docs/index.md Normal file
View file

@ -0,0 +1,55 @@
---
layout: home
hero:
name: VaultLink
text: Self-Hosted Obsidian Sync
tagline: Edit with any tool. Automatic conflict-free merging. Your infrastructure.
image:
src: /logo.svg
alt: VaultLink
actions:
- theme: brand
text: Get Started
link: /guide/getting-started
- theme: alt
text: Why VaultLink?
link: /guide/what-is-vaultlink
features:
- title: Edit Anywhere
details: Use Obsidian, Vim, VS Code, or any editor. VaultLink syncs files, not keystrokes—edit however you want
- title: Your Data, Your Server
details: Fully self-hosted. No third parties, no subscriptions, no data mining. Single Docker container or binary
- title: No Conflict Markers
details: Automatic merge using operational transformation. Never see conflict markers in your notes again
- title: Real-Time Collaboration
details: See teammate cursors, merge edits instantly. Rust-powered WebSocket server with SQLite
- title: Open Source Everything
details: MIT licensed. Server, clients, and sync algorithm are all open source. No proprietary components
- title: Battle-Tested
details: Comprehensive test suite. E2E tests. Used in production. Unlike alternatives with zero tests
---
## Why Self-Host?
**You own your knowledge base.** Commercial sync services can disappear, change pricing, or lock you out. VaultLink runs on your infrastructure—VPS, home server, or localhost.
**Edit with any tool.** Other solutions require CRDT-aware editors or break when you edit outside Obsidian. VaultLink uses differential sync: edit files however you want, sync handles the rest.
**No conflict markers.** Git forces manual merging. Other tools use last-write-wins. VaultLink's operational transformation automatically merges Markdown and text files without conflict markers or workflow interruption. [See what's supported →](/guide/limitations)
[See how VaultLink compares to alternatives →](/guide/alternatives)
## Quick Start
Deploy server (single command):
```bash
docker run -d -p 3000:3000 -v $(pwd)/data:/data \
ghcr.io/schmelczer/vault-link-server:latest
```
Then install the [Obsidian plugin](/guide/obsidian-plugin) or [CLI client](/guide/cli-client).
[Full setup guide →](/guide/getting-started)

25
docs/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "docs",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vitepress dev",
"build": "vitepress build",
"preview": "vitepress preview",
"format": "prettier --write \"**/*.md\" \"**/*.mts\"",
"format:check": "prettier --check \"**/*.md\" \"**/*.mts\"",
"spell": "cspell \"**/*.md\" \"**/*.mts\"",
"spell:check": "cspell \"**/*.md\" \"**/*.mts\""
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@cspell/dict-en-gb": "^5.0.19",
"cspell": "^9.3.2",
"prettier": "^3.6.2",
"vitepress": "^1.6.4",
"vue": "^3.5.24"
}
}

47
docs/public/logo.svg Normal file
View file

@ -0,0 +1,47 @@
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Background circle -->
<circle cx="100" cy="100" r="90" fill="url(#grad1)" opacity="0.15"/>
<!-- Main vault icon -->
<g transform="translate(100, 100)">
<!-- Vault body -->
<rect x="-45" y="-50" width="90" height="80" rx="8" fill="none" stroke="url(#grad1)" stroke-width="6"/>
<!-- Vault door circle -->
<circle cx="0" cy="-10" r="22" fill="none" stroke="url(#grad1)" stroke-width="5"/>
<circle cx="0" cy="-10" r="14" fill="none" stroke="url(#grad1)" stroke-width="3"/>
<circle cx="0" cy="-10" r="6" fill="url(#grad1)"/>
<!-- Vault handle -->
<line x1="0" y1="-4" x2="18" y2="-4" stroke="url(#grad1)" stroke-width="3" stroke-linecap="round"/>
<circle cx="18" cy="-4" r="4" fill="url(#grad1)"/>
<!-- Link chain -->
<g opacity="0.9">
<!-- Left link -->
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Right link -->
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
<!-- Center link connecting them -->
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
</g>
<!-- Sync arrows (subtle) -->
<g opacity="0.5">
<!-- Clockwise arrow top-right -->
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
<!-- Counter-clockwise arrow bottom-left -->
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -37,6 +37,24 @@ export default [
"@typescript-eslint/no-magic-numbers": "off",
"@typescript-eslint/prefer-readonly-parameter-types": "off",
"@typescript-eslint/naming-convention": "off",
"no-restricted-properties": [
"error",
{
object: "Promise",
property: "all",
message: "Use `awaitAll` instead of Promise.all to always await all promises."
},
{
object: "Promise",
property: "allSettled",
message: "Use `awaitAll` instead of Promise.allSettled to always await all promises and throw on errors."
},
{
object: "String",
property: "replace",
message: "Use replaceAll instead of replace to replace all occurrences of a substring."
}
],
"unused-imports/no-unused-vars": [
"warn",
{

View file

@ -87,19 +87,20 @@ async function main(): Promise<void> {
];
const settings: SyncSettings = {
...DEFAULT_SETTINGS,
remoteUri: args.remoteUri,
token: args.token,
vaultName: args.vaultName,
syncConcurrency:
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
diffCacheSizeMB: DEFAULT_SETTINGS.diffCacheSizeMB,
ignorePatterns,
webSocketRetryIntervalMs:
args.webSocketRetryIntervalMs ??
DEFAULT_SETTINGS.webSocketRetryIntervalMs,
isSyncEnabled: true,
enableTelemetry: args.enableTelemetry ?? false
enableTelemetry:
args.enableTelemetry ?? DEFAULT_SETTINGS.enableTelemetry
};
const client = await SyncClient.create({
@ -187,7 +188,8 @@ async function main(): Promise<void> {
);
fileWatcher.stop();
await client.waitAndStop();
await client.waitUntilFinished();
await client.destroy();
console.log(colorize("Shutdown complete", "green"));
process.exit(0);
};
@ -226,7 +228,7 @@ async function main(): Promise<void> {
);
fileWatcher.stop();
await client.waitAndStop();
await client.destroy();
process.exit(1);
}
}

View file

@ -1,8 +1,11 @@
import * as fs from "fs/promises";
import type { Dirent } from "fs";
import * as path from "path";
import type { FileSystemOperations, RelativePath } from "sync-client";
import type { TextWithCursors } from "reconcile-text";
import type {
FileSystemOperations,
RelativePath,
TextWithCursors
} from "sync-client";
export class NodeFileSystemOperations implements FileSystemOperations {
public constructor(private readonly basePath: string) {}

View file

@ -1,12 +1,12 @@
import type { Stat, Vault, Workspace } from "obsidian";
import { MarkdownView, normalizePath } from "obsidian";
import type { CursorPosition, TextWithCursors } from "sync-client";
import {
utils,
type FileSystemOperations,
type RelativePath
} from "sync-client";
import { getSelectionsFromEditor } from "./views/cursors/get-selections-from-editor";
import type { TextWithCursors, CursorPosition } from "reconcile-text";
export class ObsidianFileSystemOperations implements FileSystemOperations {
public constructor(

View file

@ -5,7 +5,7 @@ import type {
TAbstractFile,
WorkspaceLeaf
} from "obsidian";
import { Platform, Plugin, TFile } from "obsidian";
import { Notice, Platform, Plugin, TFile } from "obsidian";
import "../manifest.json";
import { HistoryView } from "./views/history/history-view";
import { StatusBar } from "./views/status-bar/status-bar";
@ -30,124 +30,59 @@ import { LocalCursorUpdateListener } from "./views/cursors/local-cursor-update-l
import { renderCursorsInFileExplorer } from "./views/cursors/file-explorer";
const MIN_WAIT_BETWEEN_UPDATES_IN_MS = 250;
const IS_DEBUG_BUILD = process.env.NODE_ENV === "development";
export default class VaultLinkPlugin extends Plugin {
private readonly disposables: (() => unknown)[] = [];
private settingsTab: SyncSettingsTab | undefined;
private client!: SyncClient;
private readonly rateLimitedUpdatesPerFile = new Map<
string,
() => Promise<unknown>
>();
private readonly syncClient: SyncClient | undefined;
private settingsTab: SyncSettingsTab | undefined;
public async onload(): Promise<void> {
DEFAULT_SETTINGS.ignorePatterns.push(
".obsidian/**",
".git/**",
".trash/**"
);
const isDebugBuild = process.env.NODE_ENV === "development";
const debugOptions = isDebugBuild
? {
fetch: debugging.slowFetchFactory(1),
webSocket: debugging.slowWebSocketFactory(1, new Logger())
}
: {};
this.client = await SyncClient.create({
fs: new ObsidianFileSystemOperations(
this.app.vault,
this.app.workspace
),
persistence: {
load: this.loadData.bind(this),
save: this.saveData.bind(this)
},
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
...debugOptions
});
if (isDebugBuild) {
debugging.logToConsole(this.client);
}
const statusDescription = new StatusDescription(this.client);
this.settingsTab = new SyncSettingsTab({
app: this.app,
plugin: this,
syncClient: this.client,
statusDescription
});
this.addSettingTab(this.settingsTab);
new StatusBar(this, this.client);
this.registerView(
HistoryView.TYPE,
(leaf) => new HistoryView(this.client, leaf)
);
this.registerView(
LogsView.TYPE,
(leaf) => new LogsView(this.client, leaf)
);
this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]);
this.client.addRemoteCursorsUpdateListener((cursors) => {
RemoteCursorsPluginValue.setCursors(cursors, this.app);
renderCursorsInFileExplorer(cursors, this.app);
});
const cursorListener = new LocalCursorUpdateListener(
this.client,
this.app.workspace
);
this.disposables.push(() => {
cursorListener.dispose();
});
this.app.workspace.updateOptions();
this.addRibbonIcon(
HistoryView.ICON,
"Open VaultLink events",
async (_: MouseEvent) => this.activateView(HistoryView.TYPE)
);
this.addRibbonIcon(
LogsView.ICON,
"Open VaultLink logs",
async (_: MouseEvent) => this.activateView(LogsView.TYPE)
);
this.app.workspace.onLayoutReady(async () => {
this.registerEditorEvents();
await this.client.start();
// eslint-disable-next-line
if ((globalThis as any).VAULT_LINK_RUNNING_INSTANCE) {
new Notice(
"Another instance of VaultLink is already running. Please disable the duplicate instance."
);
throw new Error("VaultLink instance already running");
}
// eslint-disable-next-line
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = this;
const editorStatusDisplayManager = new EditorStatusDisplayManager(
this,
this.app.workspace,
this.client
);
this.disposables.push(() => {
editorStatusDisplayManager.stop();
const client = await this.createSyncClient();
this.registerObsidianExtensions(client);
this.registerEditorEvents(client);
this.register(async () => {
await client.waitUntilFinished();
await client.destroy();
});
await client.start();
});
}
public onunload(): void {
this.client.waitAndStop().catch((err: unknown) => {
this.client.logger.error(
`Error while stopping the sync client: ${err}`
public onUserEnable(): void {
new Notice(
"VaultLink has been enabled, check out the docs for tips on getting started!"
);
void this.activateView(HistoryView.TYPE).catch((e: unknown) => {
this.syncClient?.logger.error(
`Failed to open history view on enable: ${e}`
);
});
this.disposables.forEach((disposable) => {
disposable();
void this.activateView(LogsView.TYPE).catch((e: unknown) => {
this.syncClient?.logger.error(
`Failed to open logs view on enable: ${e}`
);
});
this.openSettings();
}
public openSettings(): void {
@ -180,7 +115,112 @@ export default class VaultLinkPlugin extends Plugin {
}
}
private registerEditorEvents(): void {
private async createSyncClient(): Promise<SyncClient> {
DEFAULT_SETTINGS.ignorePatterns.push(
".obsidian/**",
".git/**",
".trash/**",
"**/.DS_Store"
);
const client = await SyncClient.create({
fs: new ObsidianFileSystemOperations(
this.app.vault,
this.app.workspace
),
persistence: {
load: this.loadData.bind(this),
save: this.saveData.bind(this)
},
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
...(IS_DEBUG_BUILD
? {
fetch: debugging.slowFetchFactory(1),
webSocket: debugging.slowWebSocketFactory(
1,
new Logger()
)
}
: {})
});
if (IS_DEBUG_BUILD) {
debugging.logToConsole(client);
}
return client;
}
private registerObsidianExtensions(client: SyncClient): void {
const statusDescription = new StatusDescription(client);
this.settingsTab = new SyncSettingsTab({
app: this.app,
plugin: this,
syncClient: client,
statusDescription
});
this.addSettingTab(this.settingsTab);
new StatusBar(this, client);
this.registerView(HistoryView.TYPE, (leaf) => {
const view = new HistoryView(client, leaf);
this.register(async () => view.onClose());
return view;
});
this.registerView(LogsView.TYPE, (leaf) => new LogsView(client, leaf));
this.registerEditorExtension([remoteCursorsTheme, remoteCursorsPlugin]);
client.addRemoteCursorsUpdateListener((cursors) => {
RemoteCursorsPluginValue.setCursors(cursors, this.app);
renderCursorsInFileExplorer(cursors, this.app);
});
const cursorListener = new LocalCursorUpdateListener(
client,
this.app.workspace
);
this.register(() => {
cursorListener.dispose();
});
this.app.workspace.updateOptions();
this.addRibbonIcons();
const editorStatusDisplayManager = new EditorStatusDisplayManager(
this,
this.app.workspace,
client
);
this.register(() => {
editorStatusDisplayManager.dispose();
});
this.register(() => {
// eslint-disable-next-line
(globalThis as any).VAULT_LINK_RUNNING_INSTANCE = null;
});
}
private addRibbonIcons(): void {
this.addRibbonIcon(
HistoryView.ICON,
"Open VaultLink events",
async (_: MouseEvent) => this.activateView(HistoryView.TYPE)
);
this.addRibbonIcon(
LogsView.ICON,
"Open VaultLink logs",
async (_: MouseEvent) => this.activateView(LogsView.TYPE)
);
}
private registerEditorEvents(client: SyncClient): void {
[
this.app.workspace.on(
"editor-change",
@ -190,28 +230,28 @@ export default class VaultLinkPlugin extends Plugin {
) => {
const { file } = info;
if (file) {
await this.rateLimitedUpdate(file.path);
await this.rateLimitedUpdate(file.path, client);
}
}
),
this.app.vault.on("create", async (file: TAbstractFile) => {
if (file instanceof TFile) {
await this.client.syncLocallyCreatedFile(file.path);
await client.syncLocallyCreatedFile(file.path);
}
}),
this.app.vault.on("modify", async (file: TAbstractFile) => {
if (file instanceof TFile) {
await this.rateLimitedUpdate(file.path);
await this.rateLimitedUpdate(file.path, client);
}
}),
this.app.vault.on("delete", async (file: TAbstractFile) => {
await this.client.syncLocallyDeletedFile(file.path);
await client.syncLocallyDeletedFile(file.path);
}),
this.app.vault.on(
"rename",
async (file: TAbstractFile, oldPath: string) => {
if (file instanceof TFile) {
await this.client.syncLocallyUpdatedFile({
await client.syncLocallyUpdatedFile({
oldPath,
relativePath: file.path
});
@ -223,13 +263,16 @@ export default class VaultLinkPlugin extends Plugin {
});
}
private async rateLimitedUpdate(path: string): Promise<void> {
private async rateLimitedUpdate(
path: string,
client: SyncClient
): Promise<void> {
if (!this.rateLimitedUpdatesPerFile.has(path)) {
this.rateLimitedUpdatesPerFile.set(
path,
rateLimit(
async () =>
this.client.syncLocallyUpdatedFile({
client.syncLocallyUpdatedFile({
relativePath: path
}),
MIN_WAIT_BETWEEN_UPDATES_IN_MS

View file

@ -22,7 +22,7 @@ export class EditorStatusDisplayManager {
}, EditorStatusDisplayManager.UPDATE_INTERVAL_IN_MS);
}
public stop(): void {
public dispose(): void {
clearInterval(this.intervalId);
}

View file

@ -108,6 +108,7 @@ export class HistoryView extends ItemView {
this.historyContainer = container.createDiv({ cls: "logs-container" });
await this.updateView();
this.clearTimer();
this.timer = setInterval(
() =>
void this.updateView().catch((error: unknown) => {
@ -120,8 +121,13 @@ export class HistoryView extends ItemView {
}
public async onClose(): Promise<void> {
this.clearTimer();
}
private clearTimer(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}

View file

@ -14,8 +14,22 @@
margin: 0;
}
select {
cursor: pointer;
.logs-controls {
display: flex;
align-items: center;
gap: var(--size-4-2);
button {
display: flex;
align-items: center;
gap: var(--size-2-1);
padding: var(--size-2-2) var(--size-4-2);
cursor: pointer;
}
select {
cursor: pointer;
}
}
}

View file

@ -1,7 +1,7 @@
import "./logs-view.scss";
import type { WorkspaceLeaf } from "obsidian";
import { ItemView } from "obsidian";
import { ItemView, Notice, setIcon } from "obsidian";
import type { LogLine } from "sync-client";
import { LogLevel, type SyncClient } from "sync-client";
@ -78,7 +78,20 @@ export class LogsView extends ItemView {
text: "VaultLink logs"
});
verbositySection.createEl("select", {}, (dropdown) => {
const controls = verbositySection.createDiv({
cls: "logs-controls"
});
const copyButton = controls.createEl("button", {
text: "Copy logs",
cls: "clickable-icon"
});
setIcon(copyButton, "clipboard-copy");
copyButton.addEventListener("click", () => {
this.copyLogsToClipboard();
});
controls.createEl("select", {}, (dropdown) => {
logLevels.forEach(({ label, value }) =>
dropdown.createEl("option", { text: label, value })
);
@ -102,6 +115,35 @@ export class LogsView extends ItemView {
this.updateView();
}
private copyLogsToClipboard(): void {
const logs = this.client.logger.getMessages(this.minLogLevel);
if (logs.length === 0) {
new Notice("No logs to copy");
return;
}
const formattedLogs = logs
.map((logLine) => {
const timestamp = logLine.timestamp.toLocaleString();
const level = logLine.level.toUpperCase();
return `[${timestamp}] ${level}: ${logLine.message}`;
})
.join("\n");
navigator.clipboard
.writeText(formattedLogs)
.then(() => {
new Notice(`Copied ${logs.length} log entries to clipboard`);
})
.catch((error: unknown) => {
this.client.logger.error(
`Failed to copy logs to clipboard: ${error}`
);
new Notice("Failed to copy logs to clipboard");
});
}
private updateView(): void {
const container = this.logsContainer;
if (container === undefined) {

View file

@ -13,45 +13,122 @@
}
}
.vault-link-settings {
h2 {
display: flex;
align-items: center;
font-size: var(--h2-size);
.vault-link-settings-container {
position: relative;
.version {
@include number-card;
margin: var(--size-2-2) 0 0 var(--size-4-2);
background-color: var(--color-base-30);
color: var(--color-base-70);
font-size: var(--font-ui-smaller);
.vault-link-settings {
h2 {
display: flex;
align-items: center;
font-size: var(--h2-size);
.version {
@include number-card;
margin: var(--size-2-2) 0 0 var(--size-4-2);
background-color: var(--color-base-30);
color: var(--color-base-70);
font-size: var(--font-ui-smaller);
}
}
.button-container {
display: flex;
gap: var(--size-4-2);
}
h3 {
font-size: var(--font-ui-large);
margin-top: var(--heading-spacing);
}
button,
input[type="range"],
.checkbox-container,
.slider::-webkit-slider-thumb {
cursor: pointer;
}
input[type="text"],
textarea {
width: 250px;
}
textarea {
resize: none;
height: 75px;
}
.applying-changes-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translateY(-50%) translateX(-50%);
z-index: 10;
backdrop-filter: blur(10px);
.spinner-container {
background-color: rgba(var(--background-primary), 0.5);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-8);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--size-4-3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
min-width: 200px;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid var(--background-modifier-border);
border-top-color: var(--interactive-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.spinner-text {
color: var(--text-normal);
font-size: var(--font-ui-medium);
font-weight: 500;
}
.spinner-warning {
color: var(--text-muted);
font-size: var(--font-ui-small);
text-align: center;
margin-top: var(--size-2-2);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
&.applying-changes {
.setting-item-control {
pointer-events: none;
opacity: 0.5;
}
button:not(.applying-changes-overlay button) {
pointer-events: none;
opacity: 0.5;
}
input,
textarea,
select {
pointer-events: none;
opacity: 0.5;
}
}
}
.button-container {
display: flex;
gap: var(--size-4-2);
}
h3 {
font-size: var(--font-ui-large);
margin-top: var(--heading-spacing);
}
button,
input[type="range"],
.checkbox-container,
.slider::-webkit-slider-thumb {
cursor: pointer;
}
input[type="text"],
textarea {
width: 250px;
}
textarea {
resize: none;
height: 75px;
}
}
}

View file

@ -13,6 +13,9 @@ export class SyncSettingsTab extends PluginSettingTab {
private editedToken: string;
private editedVaultName: string;
private _isApplyingChanges = false;
private syncEnabledOverride: boolean | undefined = undefined;
private readonly plugin: VaultLinkPlugin;
private readonly syncClient: SyncClient;
private readonly statusDescription: StatusDescription;
@ -64,11 +67,28 @@ export class SyncSettingsTab extends PluginSettingTab {
);
}
private get isApplyingChanges(): boolean {
return this._isApplyingChanges;
}
private set isApplyingChanges(value: boolean) {
this._isApplyingChanges = value;
this.display();
}
public display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.addClass("vault-link-settings");
containerEl.parentElement?.addClass("vault-link-settings-container");
if (this.isApplyingChanges) {
containerEl.addClass("applying-changes");
} else {
containerEl.removeClass("applying-changes");
}
this.renderApplyingChanges(containerEl);
this.renderSettingsHeader(containerEl);
this.renderConnectionSettings(containerEl);
this.renderSyncSettings(containerEl);
@ -80,6 +100,32 @@ export class SyncSettingsTab extends PluginSettingTab {
this.setStatusDescriptionSubscription();
}
private renderApplyingChanges(containerEl: HTMLElement): void {
if (this.isApplyingChanges) {
const overlay = containerEl.createDiv({
cls: "applying-changes-overlay"
});
const spinnerContainer = overlay.createDiv({
cls: "spinner-container"
});
spinnerContainer.createDiv({
cls: "spinner"
});
spinnerContainer.createDiv({
text: "Applying changes...",
cls: "spinner-text"
});
spinnerContainer.createDiv({
text: "You can exit, but changes won't be saved",
cls: "spinner-warning"
});
}
}
private renderSettingsHeader(containerEl: HTMLElement): void {
containerEl.createEl("h2", { text: "VaultLink" }).createSpan({
text: this.plugin.manifest.version,
@ -197,23 +243,40 @@ export class SyncSettingsTab extends PluginSettingTab {
new Setting(containerEl).addButton((button) =>
button
.setButtonText("Apply & test connection")
.onClick(async () => {
if (this.areThereUnsavedChanges()) {
await this.syncClient.setSettings({
vaultName: this.editedVaultName,
remoteUri: this.editedServerUri,
token: this.editedToken
});
new Notice("Checking connection to the server...");
new Notice(
(
await this.syncClient.checkConnection()
).serverMessage
);
await this.statusDescription.updateConnectionState();
} else {
new Notice("No changes to apply");
}
.setDisabled(this.isApplyingChanges)
.setTooltip(
this.isApplyingChanges
? "Waiting for applying changes to finish..."
: "Apply the changes made to the connection settings and test the connection to the server."
)
.onClick(() => {
// don't show loader within the button
void (async (): Promise<void> => {
if (this.areThereUnsavedChanges()) {
new Notice("Applying changes to the server...");
this.isApplyingChanges = true;
try {
await this.syncClient.setSettings({
vaultName: this.editedVaultName,
remoteUri: this.editedServerUri,
token: this.editedToken
});
} finally {
this.isApplyingChanges = false;
}
new Notice("Checking connection to the server...");
new Notice(
(
await this.syncClient.checkConnection()
).serverMessage
);
await this.statusDescription.updateConnectionState();
} else {
new Notice("No changes to apply");
}
})();
})
);
}
@ -239,9 +302,31 @@ export class SyncSettingsTab extends PluginSettingTab {
)
.addToggle((toggle) =>
toggle
.setValue(this.syncClient.getSettings().isSyncEnabled)
.onChange(async (value) =>
this.syncClient.setSetting("isSyncEnabled", value)
.setValue(
this.syncEnabledOverride ??
this.syncClient.getSettings().isSyncEnabled
)
.setDisabled(this.isApplyingChanges)
.setTooltip(
this.isApplyingChanges
? "Waiting for applying changes to finish..."
: "Enable or disable syncing."
)
.onChange(
(value) =>
void (async (): Promise<void> => {
this.syncEnabledOverride = value;
this.isApplyingChanges = true;
try {
await this.syncClient.setSetting(
"isSyncEnabled",
value
);
} finally {
this.syncEnabledOverride = undefined;
this.isApplyingChanges = false;
}
})()
)
);
@ -321,12 +406,29 @@ export class SyncSettingsTab extends PluginSettingTab {
"Delete the local metadata database while leaving the local and remote files intact."
)
.addButton((button) =>
button.setButtonText("Reset sync state").onClick(async () => {
await this.syncClient.reset();
new Notice(
"Sync state has been reset, you will need to resync"
);
})
button
.setDisabled(this.isApplyingChanges)
.setTooltip(
this.isApplyingChanges
? "Waiting for applying changes to finish..."
: "Reset sync state"
)
.setButtonText("Reset sync state")
.onClick(
() =>
void (async (): Promise<void> => {
this.isApplyingChanges = true;
try {
await this.syncClient.reset();
} finally {
this.isApplyingChanges = false;
}
new Notice(
"Sync state has been reset, you will need to resync"
);
})()
)
);
}
@ -348,6 +450,76 @@ export class SyncSettingsTab extends PluginSettingTab {
this.syncClient.setSetting("enableTelemetry", value)
)
);
containerEl.createEl("h3", { text: "Advanced" });
new Setting(containerEl)
.setName("Network retry interval (ms)")
.setDesc(
"The time to wait between retrying failed network requests, in milliseconds."
)
.addText((input) =>
input
.setValue(
this.syncClient
.getSettings()
.networkRetryIntervalMs.toString()
)
.onChange(async (value) => {
if (value === "") {
return;
}
let parsedValue = Number.parseInt(value, 10);
if (Number.isNaN(parsedValue) || parsedValue < 0) {
parsedValue =
this.syncClient.getSettings()
.networkRetryIntervalMs;
}
if (value !== parsedValue.toString()) {
input.setValue(parsedValue.toString());
}
return this.syncClient.setSetting(
"networkRetryIntervalMs",
parsedValue
);
})
);
new Setting(containerEl)
.setName("Minimum save interval (ms)")
.setDesc(
"The minimum time between saving settings and database to disk, in milliseconds. Lower values save more frequently but may impact performance."
)
.addText((input) =>
input
.setValue(
this.syncClient
.getSettings()
.minimumSaveIntervalMs.toString()
)
.onChange(async (value) => {
if (value === "") {
return;
}
let parsedValue = Number.parseInt(value, 10);
if (Number.isNaN(parsedValue) || parsedValue < 0) {
parsedValue =
this.syncClient.getSettings()
.minimumSaveIntervalMs;
}
if (value !== parsedValue.toString()) {
input.setValue(parsedValue.toString());
}
return this.syncClient.setSetting(
"minimumSaveIntervalMs",
parsedValue
);
})
);
}
private setStatusDescriptionSubscription(

View file

@ -1565,6 +1565,7 @@
},
"node_modules/balanced-match": {
"version": "1.0.2",
"dev": true,
"license": "MIT"
},
"node_modules/big.js": {
@ -1649,6 +1650,7 @@
},
"node_modules/byte-base64": {
"version": "1.1.0",
"dev": true,
"license": "MIT"
},
"node_modules/call-bind-apply-helpers": {
@ -2251,6 +2253,7 @@
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"dev": true,
"license": "MIT"
},
"node_modules/events": {
@ -3146,6 +3149,7 @@
},
"node_modules/p-queue": {
"version": "8.1.0",
"dev": true,
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.1",
@ -3160,6 +3164,7 @@
},
"node_modules/p-timeout": {
"version": "6.1.4",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.16"
@ -3484,6 +3489,7 @@
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.7.1.tgz",
"integrity": "sha512-khedcYvAKs7ELKh5Z8mz2vyomMY5TqznV1dB+k/7qUAX9cheMNN5/EPJVQYZepOMunYbnQitvhFJX3kD4IMcNw==",
"dev": true,
"license": "MIT"
},
"node_modules/regex-parser": {
@ -4303,6 +4309,7 @@
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
@ -4664,20 +4671,18 @@
},
"sync-client": {
"version": "0.10.1",
"dependencies": {
"devDependencies": {
"@sentry/browser": "^10.8.0",
"@types/node": "^24.8.1",
"byte-base64": "^1.1.0",
"minimatch": "^10.0.1",
"p-queue": "^8.1.0",
"reconcile-text": "^0.7.1",
"uuid": "^13.0.0"
},
"devDependencies": {
"@sentry/browser": "^10.8.0",
"@types/node": "^24.8.1",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",
"tsx": "^4.20.5",
"typescript": "5.8.3",
"uuid": "^13.0.0",
"webpack": "^5.99.9",
"webpack-cli": "^6.0.1",
"webpack-merge": "^6.0.1",
@ -4688,6 +4693,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@ -4695,6 +4701,7 @@
},
"sync-client/node_modules/minimatch": {
"version": "10.0.1",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"

View file

@ -12,14 +12,12 @@
"build": "webpack --mode production",
"test": "tsx --test src/**/*.test.ts"
},
"dependencies": {
"devDependencies": {
"byte-base64": "^1.1.0",
"minimatch": "^10.0.1",
"p-queue": "^8.1.0",
"reconcile-text": "^0.7.1",
"uuid": "^13.0.0"
},
"devDependencies": {
"uuid": "^13.0.0",
"@types/node": "^24.8.1",
"ts-loader": "^9.5.2",
"tslib": "2.8.1",

View file

@ -0,0 +1,6 @@
export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60;
export const DIFF_CACHE_SIZE_MB = 2;
export const MAX_LOG_MESSAGE_COUNT = 100000;
export const MAX_HISTORY_ENTRY_COUNT = 5000;
export const SUPPORTED_API_VERSION = 1;
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10;

View file

@ -1,5 +1,8 @@
export class FileNotFoundError extends Error {
public constructor(message: string) {
public constructor(
message: string,
public readonly filePath: string
) {
super(message);
this.name = "FileNotFoundError";
}

View file

@ -9,6 +9,17 @@ import { Logger } from "../tracing/logger";
import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
import type { FileSystemOperations } from "./filesystem-operations";
import type { TextWithCursors } from "reconcile-text";
import type { ServerConfig, ServerConfigData } from "../services/server-config";
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
public getConfig(): ServerConfigData {
return {
mergeableFileExtensions: ["md", "txt"],
supportedApiVersion: 1,
isAuthenticated: true
};
}
}
class MockDatabase implements Partial<Database> {
public getLatestDocumentByRelativePath(
@ -79,7 +90,8 @@ describe("File operations", () => {
const fileOperations = new FileOperations(
new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations
fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
);
await fileOperations.create("a", new Uint8Array());
@ -108,7 +120,8 @@ describe("File operations", () => {
const fileOperations = new FileOperations(
new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations
fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
);
await fileOperations.create("b.md", new Uint8Array());
@ -147,7 +160,8 @@ describe("File operations", () => {
const fileOperations = new FileOperations(
new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations
fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
);
await fileOperations.create("a/b.c/d", new Uint8Array());
@ -159,4 +173,63 @@ describe("File operations", () => {
"a/b.c/e (1)"
);
});
it("should continue deconfliction from existing number in filename", async () => {
const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations(
new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
);
await fileOperations.create("document (5).md", new Uint8Array());
await fileOperations.create("other.md", new Uint8Array());
await fileOperations.move("other.md", "document (5).md");
assertSetContainsExactly(
fileSystemOperations.names,
"document (5).md",
"document (6).md"
);
await fileOperations.create("another.md", new Uint8Array());
await fileOperations.move("another.md", "document (5).md");
assertSetContainsExactly(
fileSystemOperations.names,
"document (5).md",
"document (6).md",
"document (7).md"
);
});
it("should handle dotfiles correctly", async () => {
const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations(
new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
);
await fileOperations.create(".gitignore", new Uint8Array());
await fileOperations.create("temp", new Uint8Array());
await fileOperations.move("temp", ".gitignore");
assertSetContainsExactly(
fileSystemOperations.names,
".gitignore",
".gitignore (1)"
);
await fileOperations.create(".config.json", new Uint8Array());
await fileOperations.create("temp2", new Uint8Array());
await fileOperations.move("temp2", ".config.json");
assertSetContainsExactly(
fileSystemOperations.names,
".gitignore",
".gitignore (1)",
".config.json",
".config (1).json"
);
});
});

View file

@ -6,15 +6,17 @@ import type { TextWithCursors } from "reconcile-text";
import { reconcile } from "reconcile-text";
import { isFileTypeMergable } from "../utils/is-file-type-mergable";
import { isBinary } from "../utils/is-binary";
import type { ServerConfig } from "../services/server-config";
export class FileOperations {
private static readonly PARENTHESES_REGEX = / \((\d+)\)$/;
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
private readonly fs: SafeFileSystemOperations;
public constructor(
private readonly logger: Logger,
private readonly database: Database,
fs: FileSystemOperations,
private readonly serverConfig: ServerConfig,
private readonly nativeLineEndings = "\n"
) {
this.fs = new SafeFileSystemOperations(fs, logger);
@ -25,7 +27,7 @@ export class FileOperations {
): [RelativePath, RelativePath] {
const pathParts = path.split("/");
const fileName = pathParts.pop();
if (fileName == "" || fileName == null) {
if (fileName == null || fileName === "") {
throw new Error(`Path '${path}' cannot be empty`);
}
@ -59,12 +61,16 @@ export class FileOperations {
public async ensureClearPath(path: RelativePath): Promise<void> {
if (await this.fs.exists(path)) {
const deconflictedPath = await this.deconflictPath(path);
this.logger.debug(
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
);
try {
this.logger.debug(
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
);
this.database.move(path, deconflictedPath);
await this.fs.rename(path, deconflictedPath);
this.database.move(path, deconflictedPath);
await this.fs.rename(path, deconflictedPath, true);
} finally {
this.fs.unlock(deconflictedPath);
}
} else {
await this.createParentDirectories(path);
}
@ -89,7 +95,10 @@ export class FileOperations {
}
if (
!isFileTypeMergable(path) ||
!isFileTypeMergable(
path,
this.serverConfig.getConfig().mergeableFileExtensions
) ||
isBinary(expectedContent) ||
isBinary(newContent)
) {
@ -114,14 +123,14 @@ export class FileOperations {
`Performing a 3-way merge for ${path} with the expected content`
);
text = text.replace(this.nativeLineEndings, "\n");
text = text.replaceAll(this.nativeLineEndings, "\n");
const merged = reconcile(
expectedText,
{ text, cursors },
newText
);
const resultText = merged.text.replace(
const resultText = merged.text.replaceAll(
"\n",
this.nativeLineEndings
);
@ -166,6 +175,10 @@ export class FileOperations {
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
}
public reset(): void {
this.fs.reset();
}
private async deletingEmptyParentDirectoriesOfDeletedFile(
path: RelativePath
): Promise<void> {
@ -197,7 +210,7 @@ export class FileOperations {
const decoder = new TextDecoder("utf-8");
let text = decoder.decode(content);
text = text.replace(this.nativeLineEndings, "\n");
text = text.replaceAll(this.nativeLineEndings, "\n");
return new TextEncoder().encode(text);
}
@ -208,7 +221,7 @@ export class FileOperations {
const decoder = new TextDecoder("utf-8");
let text = decoder.decode(content);
text = text.replace("\n", this.nativeLineEndings);
text = text.replaceAll("\n", this.nativeLineEndings);
return new TextEncoder().encode(text);
}
@ -225,6 +238,13 @@ export class FileOperations {
}
}
/**
* Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found.
* The returned path has a lock acquired on it; it must be released by the caller when no longer needed.
*
* @param path The starting path to deconflict
* @returns a non-existent path with a lock acquired on it
*/
private async deconflictPath(path: RelativePath): Promise<RelativePath> {
// eslint-disable-next-line prefer-const
let [directory, fileName] = FileOperations.getParentDirAndFile(path);
@ -234,20 +254,38 @@ export class FileOperations {
}
const nameParts = fileName.split(".");
// Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json"
const isDotfile = fileName.startsWith(".") && nameParts[0] === "";
const extension =
nameParts.length > 1 ? "." + nameParts[nameParts.length - 1] : "";
nameParts.length > 1 && !(isDotfile && nameParts.length === 2)
? "." + nameParts[nameParts.length - 1]
: "";
let stem = extension ? nameParts.slice(0, -1).join(".") : fileName;
let currentCount = Number.parseInt(
FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.[0] ?? "0"
FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0"
);
stem = stem.replace(FileOperations.PARENTHESES_REGEX, "");
let newName = path;
do {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
currentCount++;
newName = `${directory}${stem} (${currentCount})${extension}`;
} while (await this.fs.exists(newName));
return newName;
// Avoid multiple deconflictPath calls returning the same path
if (this.fs.tryLock(newName)) {
const newDocument =
this.database.getLatestDocumentByRelativePath(newName);
if (
newDocument?.isDeleted === false || // the document might have been confirmed by the server at a new path but haven't yet moved there locally
(await this.fs.exists(newName, true))
) {
this.fs.unlock(newName);
} else {
return newName;
}
}
}
}
}

View file

@ -1,7 +1,7 @@
import type { RelativePath } from "../persistence/database";
import type { FileSystemOperations } from "./filesystem-operations";
import type { Logger } from "../tracing/logger";
import { Locks } from "../utils/locks";
import { Locks } from "../utils/data-structures/locks";
import { FileNotFoundError } from "./file-not-found-error";
import type { TextWithCursors } from "reconcile-text";
@ -73,9 +73,16 @@ export class SafeFileSystemOperations implements FileSystemOperations {
);
}
public async exists(path: RelativePath): Promise<boolean> {
public async exists(
path: RelativePath,
skipLock = false
): Promise<boolean> {
this.logger.debug(`Checking if file '${path}' exists`);
return this.locks.withLock(path, async () => this.fs.exists(path));
if (skipLock) {
return this.fs.exists(path);
} else {
return this.locks.withLock(path, async () => this.fs.exists(path));
}
}
public async createDirectory(path: RelativePath): Promise<void> {
@ -92,19 +99,41 @@ export class SafeFileSystemOperations implements FileSystemOperations {
public async rename(
oldPath: RelativePath,
newPath: RelativePath
newPath: RelativePath,
skipLock = false
): Promise<void> {
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
return this.safeOperation(
oldPath,
async () =>
this.locks.withLock([oldPath, newPath], async () =>
this.fs.rename(oldPath, newPath)
),
async () => {
if (skipLock) {
return this.fs.rename(oldPath, newPath);
} else {
return this.locks.withLock([oldPath, newPath], async () =>
this.fs.rename(oldPath, newPath)
);
}
},
"rename"
);
}
public tryLock(path: RelativePath): boolean {
return this.locks.tryLock(path);
}
public async waitForLock(path: RelativePath): Promise<void> {
return this.locks.waitForLock(path);
}
public unlock(path: RelativePath): void {
this.locks.unlock(path);
}
public reset(): void {
this.locks.reset();
}
/**
* Decorate an operation to ensure that the file exists before running it.
* If the operation fails, it will check if the file still exists and throw
@ -117,7 +146,8 @@ export class SafeFileSystemOperations implements FileSystemOperations {
): Promise<T> {
if (!(await this.fs.exists(path))) {
throw new FileNotFoundError(
`File '${path}' not found before trying to ${operationName}`
`File not found before trying to ${operationName}`,
path
);
}
@ -131,7 +161,8 @@ export class SafeFileSystemOperations implements FileSystemOperations {
throw error;
} else {
throw new FileNotFoundError(
`File '${path}' not found when trying to ${operationName}`
`File not found when trying to ${operationName}`,
path
);
}
}

View file

@ -1,6 +1,6 @@
import { logToConsole } from "./debugging/log-to-console";
import { slowFetchFactory } from "./debugging/slow-fetch-factory";
import { slowWebSocketFactory } from "./debugging/slow-web-socket-factory";
import { logToConsole } from "./utils/debugging/log-to-console";
import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory";
import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory";
import { getRandomColor } from "./utils/get-random-color";
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
@ -25,9 +25,12 @@ export type { PersistenceProvider } from "./persistence/persistence";
export type { CursorSpan } from "./services/types/CursorSpan";
export type { ClientCursors } from "./services/types/ClientCursors";
export type { NetworkConnectionStatus } from "./types/network-connection-status";
export type { ServerVersionMismatchError } from "./services/server-version-mismatch-error";
export type { AuthenticationError } from "./services/authentication-error";
export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
export { DocumentSyncStatus } from "./types/document-sync-status";
export { SyncClient } from "./sync-client";
export type { TextWithCursors, CursorPosition } from "reconcile-text";
export const debugging = {
slowFetchFactory,

View file

@ -1,6 +1,7 @@
import type { Logger } from "../tracing/logger";
import { EMPTY_HASH } from "../utils/hash";
import { CoveredValues } from "../utils/min-covered";
import { CoveredValues } from "../utils/data-structures/min-covered";
import { awaitAll } from "../utils/await-all";
export type VaultUpdateId = number;
export type DocumentId = string;
@ -74,6 +75,10 @@ export class Database {
Math.max(0, lastSeenUpdateId ?? 0) // the first updateId will be 1 which is the first integer after -1
);
this.documents.forEach((doc) => {
this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId);
});
this.hasInitialSyncCompleted =
initialState.hasInitialSyncCompleted ?? false;
this.logger.debug(
@ -132,7 +137,7 @@ export class Database {
toUpdate.metadata = metadata;
this.save();
this.saveInTheBackground();
}
public removeDocumentPromise(promise: Promise<unknown>): void {
@ -152,7 +157,7 @@ export class Database {
public removeDocument(find: DocumentRecord): void {
this.documents = this.documents.filter((document) => document !== find);
this.save();
this.saveInTheBackground();
}
public getLatestDocumentByRelativePath(
@ -183,7 +188,7 @@ export class Database {
const currentPromises = entry.updates;
entry.updates = [...currentPromises, promise];
await Promise.all(currentPromises);
await awaitAll(currentPromises);
return entry;
}
@ -193,6 +198,9 @@ export class Database {
relativePath: RelativePath,
promise: Promise<unknown>
): DocumentRecord {
this.logger.debug(
`Creating new pending document: ${relativePath} (${documentId})`
);
const previousEntry =
this.getLatestDocumentByRelativePath(relativePath);
@ -209,7 +217,7 @@ export class Database {
};
this.documents.push(entry);
this.save();
this.saveInTheBackground();
return entry;
}
@ -233,7 +241,7 @@ export class Database {
};
this.documents.push(entry);
this.save();
this.saveInTheBackground();
return entry;
}
@ -270,7 +278,7 @@ export class Database {
oldDocument.parallelVersion =
newDocument !== undefined ? newDocument.parallelVersion + 1 : 0;
this.save();
this.saveInTheBackground();
}
public delete(relativePath: RelativePath): void {
@ -289,7 +297,7 @@ export class Database {
public setHasInitialSyncCompleted(value: boolean): void {
this.hasInitialSyncCompleted = value;
this.save();
this.saveInTheBackground();
}
public getLastSeenUpdateId(): VaultUpdateId {
@ -300,13 +308,13 @@ export class Database {
const previousMin = this.lastSeenUpdateIds.min;
this.lastSeenUpdateIds.add(value);
if (previousMin !== this.lastSeenUpdateIds.min) {
this.save();
this.saveInTheBackground();
}
}
public setLastSeenUpdateId(value: number): void {
this.lastSeenUpdateIds.min = value;
this.save();
this.saveInTheBackground();
}
public reset(): void {
@ -315,12 +323,11 @@ export class Database {
0 // the first updateId will be 1 which is the first integer after -1
);
this.hasInitialSyncCompleted = false;
this.save();
this.saveInTheBackground();
}
private save(): void {
this.ensureConsistency();
void this.saveData({
public async save(): Promise<void> {
return this.saveData({
documents: this.resolvedDocuments.map(
({ relativePath, documentId, metadata }) => ({
documentId,
@ -331,8 +338,6 @@ export class Database {
),
lastSeenUpdateId: this.lastSeenUpdateIds.min,
hasInitialSyncCompleted: this.hasInitialSyncCompleted
}).catch((error: unknown) => {
this.logger.error(`Error saving data: ${error}`);
});
}
@ -357,4 +362,11 @@ export class Database {
);
}
}
private saveInTheBackground(): void {
this.ensureConsistency();
void this.save().catch((error: unknown) => {
this.logger.error(`Error saving data: ${error}`);
});
}
}

View file

@ -1,4 +1,6 @@
import type { Logger } from "../tracing/logger";
import { awaitAll } from "../utils/await-all";
import { Lock } from "../utils/data-structures/locks";
export interface SyncSettings {
remoteUri: string;
@ -11,6 +13,8 @@ export interface SyncSettings {
webSocketRetryIntervalMs: number;
diffCacheSizeMB: number;
enableTelemetry: boolean;
networkRetryIntervalMs: number;
minimumSaveIntervalMs: number;
}
export const DEFAULT_SETTINGS: SyncSettings = {
@ -23,11 +27,14 @@ export const DEFAULT_SETTINGS: SyncSettings = {
ignorePatterns: [],
webSocketRetryIntervalMs: 3500,
diffCacheSizeMB: 4,
enableTelemetry: false
enableTelemetry: false,
networkRetryIntervalMs: 1000,
minimumSaveIntervalMs: 1000
};
export class Settings {
private settings: SyncSettings;
private readonly lock: Lock = new Lock();
private readonly onSettingsChangeHandlers: ((
newSettings: SyncSettings,
@ -54,32 +61,52 @@ export class Settings {
}
public addOnSettingsChangeListener(
handler: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
): void {
this.onSettingsChangeHandlers.push(handler);
this.onSettingsChangeHandlers.push(listener);
}
public removeOnSettingsChangeListener(
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
): void {
const index = this.onSettingsChangeHandlers.indexOf(listener);
if (index !== -1) {
this.onSettingsChangeHandlers.splice(index, 1);
}
}
public async setSetting<T extends keyof SyncSettings>(
key: T,
value: SyncSettings[T]
): Promise<void> {
this.logger.debug(`Setting '${key}' to '${value}'`);
await this.setSettings({
[key]: value
});
}
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
const oldSettings = this.settings;
this.settings = {
...this.settings,
...value
};
await this.lock.withLock(async () => {
this.logger.debug(
`Updating settings with: ${JSON.stringify(value)}`
);
const oldSettings = this.settings;
this.settings = {
...this.settings,
...value
};
this.onSettingsChangeHandlers.forEach((handler) => {
handler(this.settings, oldSettings);
await awaitAll(
this.onSettingsChangeHandlers
.map((handler) => {
return handler(this.settings, oldSettings);
})
.filter((result): result is Promise<unknown> => {
return result instanceof Promise;
})
);
await this.save();
});
await this.save();
}
private async save(): Promise<void> {

View file

@ -0,0 +1,6 @@
export class AuthenticationError extends Error {
public constructor(message: string) {
super(message);
this.name = "AuthenticationError";
}
}

View file

@ -1,98 +0,0 @@
import type { Settings } from "../persistence/settings";
import type { Logger } from "../tracing/logger";
import { createPromise } from "../utils/create-promise";
import { SyncResetError } from "./sync-reset-error";
export class ConnectionStatus {
private static readonly UNTIL_RESOLUTION = Symbol();
private canFetch: boolean;
private until: Promise<symbol>;
private resolveUntil: (result: symbol) => unknown;
private rejectUntil: (reason: unknown) => unknown;
public constructor(
settings: Settings,
private readonly logger: Logger
) {
this.canFetch = settings.getSettings().isSyncEnabled;
[this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>();
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) {
this.canFetch = newSettings.isSyncEnabled;
this.resolveUntil(ConnectionStatus.UNTIL_RESOLUTION);
[this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>();
}
});
}
private static getUrlFromInput(input: RequestInfo | URL): string {
if (input instanceof URL) {
return input.href;
}
if (typeof input === "string") {
return input;
}
return input.url;
}
public startReset(): void {
this.rejectUntil(new SyncResetError());
}
public finishReset(): void {
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
}
public getFetchImplementation(
logger: Logger,
fetch: typeof globalThis.fetch = globalThis.fetch
): typeof globalThis.fetch {
return async (
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> => {
while (!this.canFetch) {
await this.until;
}
try {
// https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21
const _input =
typeof Request !== "undefined" && input instanceof Request
? input.clone()
: input;
const fetchPromise = fetch(_input, init);
// We only want to catch rejections from `this.until`
let result: symbol | Response | undefined = undefined;
do {
result = await Promise.race([this.until, fetchPromise]);
} while (result === ConnectionStatus.UNTIL_RESOLUTION);
const fetchResult: Response = result as Response; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
if (!fetchResult.ok) {
this.logger.warn(
`Fetch for ${ConnectionStatus.getUrlFromInput(
input
)}, got status ${fetchResult.status}`
);
}
return fetchResult;
} catch (error) {
logger.warn(
`Fetch for ${ConnectionStatus.getUrlFromInput(
input
)}, got error: ${error}`
);
throw error;
}
};
}
}

View file

@ -0,0 +1,177 @@
import type { Mock } from "node:test";
import { describe, it, mock, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { FetchController } from "./fetch-controller";
import { Logger } from "../tracing/logger";
import { SyncResetError } from "./sync-reset-error";
import { sleep } from "../utils/sleep";
describe("FetchController", () => {
const createMockFetch = (
shouldSleep: boolean
): Mock<() => Promise<Response>> =>
mock.fn(async () => {
if (shouldSleep) {
await sleep(30);
}
return Promise.resolve(new Response("OK", { status: 200 }));
});
beforeEach(() => {
mock.timers.enable({ apis: ["setTimeout"] });
});
afterEach(() => {
mock.timers.reset();
});
it("should allow fetch when canFetch is true", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(false);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
const response = await controlledFetch("http://example.com");
assert.strictEqual(await response.text(), "OK");
assert.strictEqual(mockFetch.mock.calls.length, 1);
});
it("should block fetch until canFetch becomes true", async () => {
const logger = new Logger();
const controller = new FetchController(false, logger);
const mockFetch = createMockFetch(true);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
const fetchPromise = controlledFetch("http://example.com");
assert.strictEqual(mockFetch.mock.calls.length, 0);
controller.canFetch = true;
await Promise.resolve();
mock.timers.tick(30);
const response = await fetchPromise;
assert.strictEqual(await response.text(), "OK");
assert.strictEqual(mockFetch.mock.calls.length, 1);
});
it("should reject during reset", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(true);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
const firstRequest = controlledFetch("http://example.com");
assert.strictEqual(mockFetch.mock.calls.length, 1);
controller.startReset();
const secondRequest = controlledFetch("http://example.com");
await assert.rejects(
firstRequest,
(error: unknown) => error instanceof SyncResetError
);
await assert.rejects(
secondRequest,
(error: unknown) => error instanceof SyncResetError
);
assert.strictEqual(mockFetch.mock.calls.length, 1);
});
it("should allow fetch after reset finishes", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(false);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
controller.startReset();
controller.finishReset();
const response = await controlledFetch("http://example.com");
assert.strictEqual(await response.text(), "OK");
});
it("should defer canFetch changes during reset", async () => {
const logger = new Logger();
const controller = new FetchController(false, logger);
const mockFetch = createMockFetch(true);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
controller.startReset();
controller.canFetch = true;
await assert.rejects(
async () => controlledFetch("http://example.com"),
(error: unknown) => error instanceof SyncResetError
);
controller.finishReset();
const fetchPromise = controlledFetch("http://example.com");
mock.timers.tick(30);
const response = await fetchPromise;
assert.strictEqual(await response.text(), "OK");
});
it("should handle different input types", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = createMockFetch(false);
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
await controlledFetch("http://example.com");
await controlledFetch(new URL("http://example.com"));
await controlledFetch(
new Request("http://example.com", { method: "POST" })
);
assert.strictEqual(mockFetch.mock.calls.length, 3);
});
it("should handle fetch errors", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
const mockFetch = mock.fn(async () => {
throw new Error("Network error");
});
const controlledFetch = controller.getControlledFetchImplementation(
logger,
mockFetch
);
await assert.rejects(
async () => controlledFetch("http://example.com"),
(error: unknown) =>
error instanceof Error && error.message === "Network error"
);
});
it("should not create unhandled rejection on reset with no waiting fetches", async () => {
const logger = new Logger();
const controller = new FetchController(true, logger);
controller.startReset();
mock.timers.tick(10);
controller.finishReset();
});
});

View file

@ -0,0 +1,149 @@
import type { Logger } from "../tracing/logger";
import { createPromise } from "../utils/create-promise";
import { SyncResetError } from "./sync-reset-error";
/**
* Offers a resettable fetch implementation that waits until syncing is enabled
* and aborts outstanding requests when a reset is started.
*/
export class FetchController {
private static readonly UNTIL_RESOLUTION = Symbol();
private isResetting = false;
// Promise resolves on the next state change: sync enabled/disabled or reset started/ended
private until: Promise<symbol>;
private resolveUntil: (result: symbol) => unknown;
private rejectUntil: (reason: unknown) => unknown;
public constructor(
private _canFetch: boolean,
private readonly logger: Logger
) {
[this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>();
}
/**
* Whether the fetch implementation can immediately send requests once outside of a reset.
*/
public get canFetch(): boolean {
return this._canFetch;
}
/**
* Allow or disallow fetching. The changes only take effect if not resetting.
* When called during a reset, its effect is deferred until the reset is finished.
*
* @param canFetch Whether fetching is enabled
*/
public set canFetch(canFetch: boolean) {
this._canFetch = canFetch;
if (!this.isResetting) {
const previousResolve = this.resolveUntil;
[this.until, this.resolveUntil, this.rejectUntil] =
createPromise<symbol>();
previousResolve(FetchController.UNTIL_RESOLUTION);
}
}
private static getUrlFromInput(input: RequestInfo | URL): string {
if (input instanceof URL) {
return input.href;
}
if (typeof input === "string") {
return input;
}
return input.url;
}
/**
* Starts a reset, causing all ongoing and future fetches to be rejected
* with a SyncResetError until finishReset is called.
*/
public startReset(): void {
this.isResetting = true;
this.rejectUntil(new SyncResetError());
// Catch unhandled rejection if no fetches are waiting
this.until.catch(() => {
// Intentionally ignore - this rejection is handled by waiting fetches
});
}
/**
* Finishes a reset, allowing fetches to proceed or wait again depending on
* the current sync settings.
*/
public finishReset(): void {
if (!this.isResetting) {
return;
}
this.isResetting = false;
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
}
/**
*
* |------------------|---------------|-----------------------------------------------------|
* | | Sync enabled | Sync disabled |
* |------------------|-------------- |-----------------------------------------------------|
* | During reset | Rejects with SyncResetError without sending request |
* |------------------|-------------- |-----------------------------------------------------|
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
* |------------------|---------------|-----------------------------------------------------|
*
* @param logger for errors
* @param fetch to wrap
* @returns a wrapped fetch implementation affected by the FetchController state
*/
public getControlledFetchImplementation(
logger: Logger,
fetch: typeof globalThis.fetch = globalThis.fetch
): typeof globalThis.fetch {
return async (
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> => {
while (!this.canFetch || this.isResetting) {
await this.until;
}
try {
// https://github.com/jonbern/fetch-retry/blob/8684ef4e688375f623bd76f13add76dbc1d67cfb/index.js#L67C1-L70C21
const _input =
typeof Request !== "undefined" && input instanceof Request
? input.clone()
: input;
const fetchPromise = fetch(_input, init);
// We only want to catch rejections from `this.until`
let result: symbol | Response | undefined = undefined;
do {
result = await Promise.race([this.until, fetchPromise]);
} while (result === FetchController.UNTIL_RESOLUTION);
const fetchResult: Response = result as Response; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
if (!fetchResult.ok) {
this.logger.warn(
`Fetch for ${FetchController.getUrlFromInput(
input
)}, got status ${fetchResult.status}`
);
}
return fetchResult;
} catch (error) {
logger.warn(
`Fetch for ${FetchController.getUrlFromInput(
input
)}, got error: ${error}`
);
throw error;
}
};
}
}

View file

@ -0,0 +1,87 @@
import { SUPPORTED_API_VERSION } from "../consts";
import { AuthenticationError } from "./authentication-error";
import { ServerVersionMismatchError } from "./server-version-mismatch-error";
import type { SyncService } from "./sync-service";
import type { PingResponse } from "./types/PingResponse";
export interface ServerConfigData {
mergeableFileExtensions: string[];
supportedApiVersion: number;
isAuthenticated: boolean;
}
export class ServerConfig {
private response: Promise<PingResponse> | undefined;
private config: ServerConfigData | undefined;
public constructor(private readonly syncService: SyncService) {}
public async initialize(): Promise<void> {
this.response = this.syncService.ping();
this.config = await this.response;
if (this.config.supportedApiVersion !== SUPPORTED_API_VERSION) {
const shouldUpgradeClient =
this.config.supportedApiVersion > SUPPORTED_API_VERSION;
throw new ServerVersionMismatchError(
`Unsupported API version: ${this.config.supportedApiVersion}. Consider upgrading the ${
shouldUpgradeClient ? "client" : "sync-server"
} to ensure compatibility.`
);
}
if (!this.config.isAuthenticated) {
throw new AuthenticationError(
"Failed to authenticate with the sync-server."
);
}
}
public async checkConnection(forceUpdate = false): Promise<{
isSuccessful: boolean;
message: string;
}> {
try {
let { response } = this;
if (!response && !forceUpdate) {
throw new Error("ServerConfig not initialized");
} else if (forceUpdate) {
response = this.response = this.syncService.ping();
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const result: PingResponse = (await response)!; // it must be defined, otherwise we would have thrown above
this.config = result;
if (result.isAuthenticated) {
return {
isSuccessful: true,
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
};
}
return {
isSuccessful: false,
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
};
} catch (e) {
return {
isSuccessful: false,
message: `Failed to connect to server: ${e}`
};
}
}
public getConfig(): ServerConfigData {
if (!this.config) {
throw new Error("ServerConfig not initialized");
}
return this.config;
}
public reset(): void {
this.response = undefined;
this.config = undefined;
}
}

View file

@ -0,0 +1,6 @@
export class ServerVersionMismatchError extends Error {
public constructor(message: string) {
super(message);
this.name = "ServerVersionMismatchError";
}
}

View file

@ -1,6 +1,6 @@
export class SyncResetError extends Error {
public constructor() {
super("Sync was reset");
super("SyncClient has been reset, cleaning up");
this.name = "SyncResetError";
}
}

View file

@ -6,7 +6,7 @@ import type {
import type { Logger } from "../tracing/logger";
import type { Settings } from "../persistence/settings";
import type { ConnectionStatus } from "./connection-status";
import type { FetchController } from "./fetch-controller";
import { sleep } from "../utils/sleep";
import { SyncResetError } from "./sync-reset-error";
import type { SerializedError } from "./types/SerializedError";
@ -18,19 +18,13 @@ import type { PingResponse } from "./types/PingResponse";
import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion";
import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion";
export interface CheckConnectionResult {
isSuccessful: boolean;
message: string;
}
export class SyncService {
private static readonly NETWORK_RETRY_INTERVAL_MS = 1000;
private readonly client: typeof globalThis.fetch;
private readonly pingClient: typeof globalThis.fetch;
public constructor(
private readonly deviceId: string,
private readonly connectionStatus: ConnectionStatus,
private readonly fetchController: FetchController,
private readonly settings: Settings,
private readonly logger: Logger,
fetchImplementation: typeof globalThis.fetch = globalThis.fetch
@ -39,7 +33,7 @@ export class SyncService {
const unboundFetch: typeof globalThis.fetch = async (...args) =>
fetchImplementation(...args);
this.client = this.connectionStatus.getFetchImplementation(
this.client = this.fetchController.getControlledFetchImplementation(
this.logger,
unboundFetch
);
@ -65,7 +59,7 @@ export class SyncService {
relativePath: RelativePath;
contentBytes: Uint8Array;
}): Promise<DocumentVersionWithoutContent> {
return this.withRetries(async () => {
return this.retryForever(async () => {
const formData = new FormData();
if (documentId !== undefined) {
formData.append("document_id", documentId);
@ -114,7 +108,7 @@ export class SyncService {
relativePath: RelativePath;
content: (number | string)[];
}): Promise<DocumentUpdateResponse> {
return this.withRetries(async () => {
return this.retryForever(async () => {
this.logger.debug(
`Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
);
@ -166,7 +160,7 @@ export class SyncService {
relativePath: RelativePath;
contentBytes: Uint8Array;
}): Promise<DocumentUpdateResponse> {
return this.withRetries(async () => {
return this.retryForever(async () => {
this.logger.debug(
`Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
);
@ -215,7 +209,7 @@ export class SyncService {
documentId: DocumentId;
relativePath: RelativePath;
}): Promise<DocumentVersionWithoutContent> {
return this.withRetries(async () => {
return this.retryForever(async () => {
const request: DeleteDocumentVersion = {
relativePath
};
@ -252,7 +246,7 @@ export class SyncService {
}: {
documentId: DocumentId;
}): Promise<DocumentVersion> {
return this.withRetries(async () => {
return this.retryForever(async () => {
const response = await this.client(
this.getUrl(`/documents/${documentId}`),
{
@ -280,7 +274,7 @@ export class SyncService {
public async getAll(
since?: VaultUpdateId
): Promise<FetchLatestDocumentsResponse> {
return this.withRetries(async () => {
return this.retryForever(async () => {
const url = new URL(this.getUrl("/documents"));
if (since !== undefined) {
url.searchParams.append("since", since.toString());
@ -308,43 +302,30 @@ export class SyncService {
});
}
public async checkConnection(): Promise<CheckConnectionResult> {
try {
const response = await this.pingClient(this.getUrl("/ping"), {
headers: this.getDefaultHeaders()
});
const result: PingResponse | SerializedError =
(await response.json()) as PingResponse | SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
public async ping(): Promise<PingResponse> {
const response = await this.pingClient(this.getUrl("/ping"), {
headers: this.getDefaultHeaders()
});
const result: PingResponse | SerializedError =
(await response.json()) as PingResponse | SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
if ("errorType" in result) {
throw new Error(
`Failed to ping server: ${SyncService.formatError(result)}`
);
}
if (result.isAuthenticated) {
return {
isSuccessful: true,
message: `Successfully connected to server (version: ${result.serverVersion}) and authenticated`
};
}
return {
isSuccessful: false,
message: `Successfully connected to server (version: ${result.serverVersion}) but failed to authenticate`
};
} catch (e) {
return {
isSuccessful: false,
message: `Failed to connect to server: ${e}`
};
if ("errorType" in result) {
throw new Error(
`Failed to ping server: ${SyncService.formatError(result)}`
);
}
this.logger.debug(
`Pinged server, got response: ${JSON.stringify(result)}`
);
return result;
}
private getUrl(path: string): string {
const { vaultName, remoteUri } = this.settings.getSettings();
const safeRemoteUri = remoteUri.replace(/\/+$/, "");
return `${safeRemoteUri}/vaults/${vaultName}${path}`;
const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, "");
return `${remoteUriWithoutTrailingSlash}/vaults/${vaultName}${path}`;
}
private getDefaultHeaders(
@ -362,7 +343,7 @@ export class SyncService {
return headers;
}
private async withRetries<T>(fn: () => Promise<T>): Promise<T> {
private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
try {
@ -373,10 +354,12 @@ export class SyncService {
throw e;
}
const retryInterval =
this.settings.getSettings().networkRetryIntervalMs;
this.logger.error(
`Failed network call (${e}), retrying in ${SyncService.NETWORK_RETRY_INTERVAL_MS}ms`
`Failed network call (${e}), retrying in ${retryInterval}ms`
);
await sleep(SyncService.NETWORK_RETRY_INTERVAL_MS);
await sleep(retryInterval);
}
}
}

View file

@ -13,4 +13,13 @@ export interface PingResponse {
* header.
*/
isAuthenticated: boolean;
/**
* List of file extensions that are allowed to be merged.
*/
mergeableFileExtensions: string[];
/**
* API version ensuring backwards & forwards compatibility between the client
* and server.
*/
supportedApiVersion: number;
}

View file

@ -0,0 +1,298 @@
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert";
import { WebSocketManager } from "./websocket-manager";
import type { Logger } from "../tracing/logger";
import type { Settings } from "../persistence/settings";
// eslint-disable-next-line @typescript-eslint/no-require-imports
const WebSocket = require("ws") as typeof globalThis.WebSocket;
class MockCloseEvent extends Event {
public code: number;
public reason: string;
public constructor(
type: string,
options: { code: number; reason: string }
) {
super(type);
this.code = options.code;
this.reason = options.reason;
}
}
class MockMessageEvent extends Event {
public data: string;
public constructor(type: string, options: { data: string }) {
super(type);
this.data = options.data;
}
}
class MockWebSocket {
public readyState: number = WebSocket.CONNECTING;
public onopen: ((event: Event) => void) | null = null;
public onclose: ((event: MockCloseEvent) => void) | null = null;
public onmessage: ((event: MockMessageEvent) => void) | null = null;
public onerror: ((event: Event) => void) | null = null;
public sentMessages: string[] = [];
public constructor(public url: string) {
setTimeout(() => {
if (this.readyState === WebSocket.CONNECTING) {
this.readyState = WebSocket.OPEN;
this.onopen?.(new Event("open"));
}
}, 0);
}
public send(data: string): void {
if (this.readyState !== WebSocket.OPEN) {
throw new Error("WebSocket is not open");
}
this.sentMessages.push(data);
}
public close(code?: number, reason?: string): void {
this.readyState = WebSocket.CLOSED;
this.onclose?.(
new MockCloseEvent("close", {
code: code ?? 1000,
reason: reason ?? ""
})
);
}
public simulateMessage(data: unknown): void {
this.onmessage?.(
new MockMessageEvent("message", { data: JSON.stringify(data) })
);
}
}
type MockFn<T extends (...args: unknown[]) => unknown> = T & {
calls: Parameters<T>[];
};
function createMockFn<T extends (...args: unknown[]) => unknown>(
implementation?: T
): MockFn<T> {
const calls: Parameters<T>[] = [];
const mockFn = ((...args: Parameters<T>) => {
calls.push(args);
return implementation?.(...args);
}) as unknown as MockFn<T>;
mockFn.calls = calls;
return mockFn;
}
describe("WebSocketManager", () => {
let mockLogger: Logger = undefined as unknown as Logger;
let mockSettings: Settings = undefined as unknown as Settings;
let deviceId = "test-device-123";
beforeEach(() => {
deviceId = "test-device-123";
const noop = (): void => {
// Intentionally empty for mock
};
mockLogger = {
info: createMockFn(noop),
warn: createMockFn(noop),
error: createMockFn(noop),
debug: createMockFn(noop)
} as unknown as Logger;
mockSettings = {
getSettings: () => ({
remoteUri: "https://example.com",
vaultName: "test-vault",
webSocketRetryIntervalMs: 1000
})
} as unknown as Settings;
});
it("cleans up promises after message handling", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
manager.addRemoteVaultUpdateListener(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[];
};
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
await new Promise((resolve) => setTimeout(resolve, 100));
assert.strictEqual(outstandingPromises.length, 0);
await manager.stop();
});
it("cleans up cursor position promises", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
manager.addRemoteCursorsUpdateListener(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[];
};
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
mockWs.simulateMessage({
type: "cursorPositions",
clients: [{ deviceId: "other-device", cursors: [] }]
});
await new Promise((resolve) => setTimeout(resolve, 100));
assert.strictEqual(outstandingPromises.length, 0);
await manager.stop();
});
it("logs handshake send errors", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
mockWs.send = (): void => {
throw new Error("Buffer full");
};
assert.throws(() => {
manager.sendHandshakeMessage({
type: "handshake",
token: "test",
deviceId: "test",
lastSeenVaultUpdateId: null
});
});
await manager.stop();
});
it("completes stop with timeout protection", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
await manager.stop();
assert.ok(true);
});
it("clears old handlers on reconnection", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
let statusChangeCount = 0;
manager.addWebSocketStatusChangeListener(() => {
statusChangeCount++;
});
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
const firstWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
statusChangeCount = 0;
(
manager as unknown as { initializeWebSocket: () => void }
).initializeWebSocket();
await new Promise((resolve) => setTimeout(resolve, 50));
statusChangeCount = 0;
// Old handler should be cleared
firstWs.onclose?.(
new MockCloseEvent("close", { code: 1000, reason: "test" })
);
assert.strictEqual(statusChangeCount, 0);
await manager.stop();
});
it("tracks message handling promises", async () => {
const manager = new WebSocketManager(
deviceId,
mockLogger,
mockSettings,
MockWebSocket as unknown as typeof WebSocket
);
// eslint-disable-next-line @typescript-eslint/init-declarations
let resolveListener: () => void;
const listenerPromise = new Promise<void>((resolve) => {
resolveListener = resolve;
});
manager.addRemoteVaultUpdateListener(async () => {
await listenerPromise;
});
manager.start();
await new Promise((resolve) => setTimeout(resolve, 50));
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
.webSocket;
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
await new Promise((resolve) => setTimeout(resolve, 10));
const { outstandingPromises } = manager as unknown as {
outstandingPromises: Promise<unknown>[];
};
assert.ok(outstandingPromises.length > 0);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
resolveListener!();
await new Promise((resolve) => setTimeout(resolve, 50));
assert.strictEqual(outstandingPromises.length, 0);
await manager.stop();
});
});

View file

@ -1,31 +1,40 @@
import type { Database } from "../persistence/database";
import type { Logger } from "../tracing/logger";
import type { Settings, SyncSettings } from "../persistence/settings";
import type { Settings } from "../persistence/settings";
import type { WebSocketServerMessage } from "./types/WebSocketServerMessage";
import type { Syncer } from "../sync-operations/syncer";
import type { WebSocketClientMessage } from "./types/WebSocketClientMessage";
import type { CursorPositionFromClient } from "./types/CursorPositionFromClient";
import type { ClientCursors } from "./types/ClientCursors";
import { createPromise } from "../utils/create-promise";
import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate";
import { awaitAll } from "../utils/await-all";
import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_S } from "../consts";
export class WebSocketManager {
private readonly webSocketStatusChangeListeners: (() => unknown)[] = [];
private readonly remoteCursorsUpdateListeners: ((
cursors: ClientCursors[]
private readonly webSocketStatusChangeListeners: ((
isConnected: boolean
) => unknown)[] = [];
private webSocket: WebSocket | undefined;
private readonly remoteVaultUpdateListeners: ((
update: WebSocketVaultUpdate
) => Promise<void>)[] = [];
private readonly remoteCursorsUpdateListeners: ((
cursors: ClientCursors[]
) => Promise<void>)[] = [];
private isStopped = true;
private _isFirstSyncCompleted = false;
private resolveDisconnectingPromise: null | (() => unknown) = null;
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
private readonly outstandingPromises: Promise<unknown>[] = [];
private webSocket: WebSocket | undefined;
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
public constructor(
private readonly deviceId: string,
private readonly logger: Logger,
private readonly database: Database,
private readonly settings: Settings,
private readonly syncer: Syncer,
webSocketImplementation?: typeof globalThis.WebSocket
) {
if (webSocketImplementation) {
@ -41,16 +50,6 @@ export class WebSocketManager {
this.webSocketFactoryImplementation = WebSocket;
}
}
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
if (
newSettings.remoteUri !== oldSettings.remoteUri ||
newSettings.vaultName !== oldSettings.vaultName ||
newSettings.token !== oldSettings.token
) {
this.initializeWebSocket(newSettings);
}
});
}
public get isWebSocketConnected(): boolean {
@ -60,96 +59,205 @@ export class WebSocketManager {
);
}
public get isFirstSyncCompleted(): boolean {
return this._isFirstSyncCompleted;
}
public addWebSocketStatusChangeListener(listener: () => unknown): void {
public addWebSocketStatusChangeListener(
listener: (isConnected: boolean) => unknown
): void {
this.webSocketStatusChangeListeners.push(listener);
}
public addRemoteCursorsUpdateListener(
listener: (cursors: ClientCursors[]) => unknown
listener: (cursors: ClientCursors[]) => Promise<void>
): void {
this.remoteCursorsUpdateListeners.push(listener);
}
public start(): void {
this.isStopped = false;
this._isFirstSyncCompleted = false;
this.initializeWebSocket(this.settings.getSettings());
public addRemoteVaultUpdateListener(
listener: (update: WebSocketVaultUpdate) => Promise<void>
): void {
this.remoteVaultUpdateListeners.push(listener);
}
public stop(): void {
public start(): void {
this.isStopped = false;
this.initializeWebSocket();
}
public async stop(): Promise<void> {
const [promise, resolve] = createPromise();
this.resolveDisconnectingPromise = resolve;
this.isStopped = true;
if (this.reconnectTimeoutId !== undefined) {
clearTimeout(this.reconnectTimeoutId);
this.reconnectTimeoutId = undefined;
}
this.webSocket?.close(1000, "WebSocketManager has been stopped");
// eslint-disable-next-line @typescript-eslint/init-declarations
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<void>((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new Error(
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds`
)
);
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000);
});
try {
while (this.isWebSocketConnected) {
await Promise.race([promise, timeoutPromise]);
}
} catch (error) {
this.logger.error(
`Error while waiting for WebSocket to close: ${String(error)}`
);
// Force cleanup even if close didn't work
this.resolveDisconnectingPromise();
this.resolveDisconnectingPromise = null;
} finally {
// Clear timeout to prevent unhandled rejection
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
await this.waitUntilFinished();
}
public async waitUntilFinished(): Promise<void> {
await awaitAll(this.outstandingPromises);
}
public sendHandshakeMessage(
message: WebSocketClientMessage & { type: "handshake" }
): void {
const { webSocket } = this;
if (!webSocket) {
throw new Error(
"WebSocket is not connected, cannot send handshake message"
);
}
try {
webSocket.send(JSON.stringify(message));
} catch (error) {
this.logger.error(
`Failed to send handshake message: ${String(error)}`
);
throw error;
}
}
public updateLocalCursors(cursorPositions: CursorPositionFromClient): void {
if (!this.isWebSocketConnected) {
if (!this.isWebSocketConnected || !this.webSocket) {
// A missing cursor update is fine, we can just skip it if needed
this.logger.warn(
"WebSocket is not connected, cannot send cursor positions"
);
return;
}
const message: WebSocketClientMessage = {
type: "cursorPositions",
...cursorPositions
};
this.webSocket?.send(JSON.stringify(message));
this.logger.debug(
`Sent cursor positions: ${JSON.stringify(cursorPositions)}`
);
}
private initializeWebSocket(settings: SyncSettings): void {
if (this.isStopped) {
return;
}
try {
this.webSocket?.close();
} catch (e) {
this.logger.warn(`Failed to close WebSocket: ${e}`);
this.webSocket.send(JSON.stringify(message));
this.logger.debug(
`Sent cursor positions: ${JSON.stringify(cursorPositions)}`
);
} catch (error) {
this.logger.warn(
`Failed to send cursor positions: ${String(error)}`
);
}
}
private initializeWebSocket(): void {
// Clean up old WebSocket handlers to prevent race conditions
if (this.webSocket) {
try {
// Remove handlers to prevent them from firing after new connection
this.webSocket.onopen = null;
this.webSocket.onclose = null;
this.webSocket.onmessage = null;
this.webSocket.onerror = null;
this.webSocket.close();
} catch (e) {
this.logger.error(
`Failed to close previous WebSocket connection: ${e}`
);
}
}
const wsUri = new URL(settings.remoteUri);
const wsUri = new URL(this.settings.getSettings().remoteUri);
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
wsUri.pathname = `/vaults/${settings.vaultName}/ws`;
wsUri.pathname = `/vaults/${this.settings.getSettings().vaultName}/ws`;
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
this.webSocket = new this.webSocketFactoryImplementation(wsUri);
// The JS WebSocket API doesn't support setting headers, so we have to send the token as a message
this.webSocket.onopen = (): void => {
this.logger.info("WebSocket connection opened");
this.webSocketStatusChangeListeners.forEach((l) => l());
const message: WebSocketClientMessage = {
type: "handshake",
deviceId: this.deviceId,
token: settings.token,
lastSeenVaultUpdateId: this.database.getLastSeenUpdateId()
};
this.webSocket?.send(JSON.stringify(message));
this.webSocketStatusChangeListeners.forEach((listener) =>
listener(true)
);
};
this.webSocket.onmessage = async (event): Promise<void> => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = JSON.parse(event.data) as WebSocketServerMessage;
return this.handleWebSocketMessage(message);
this.webSocket.onmessage = (event): void => {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = JSON.parse(
event.data
) as WebSocketServerMessage;
// Track the message handling promise
const messageHandlingPromise = this.handleWebSocketMessage(
message
)
.catch((error: unknown) => {
this.logger.error(
`Error handling WebSocket message: ${String(error)}`
);
})
.finally(() => {
const index = this.outstandingPromises.indexOf(
messageHandlingPromise
);
if (index !== -1) {
void this.outstandingPromises.splice(index, 1); // ignore the returned promise
}
});
void this.outstandingPromises.push(messageHandlingPromise); // ignore the returned promise
} catch (error) {
this.logger.error(
`Error parsing WebSocket message: ${String(error)}`
);
}
};
this.webSocket.onclose = (event): void => {
this.logger.warn(
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
);
this.webSocketStatusChangeListeners.forEach((l) => l());
this.webSocketStatusChangeListeners.forEach((listener) =>
listener(false)
);
if (!this.isStopped) {
setTimeout(() => {
this.initializeWebSocket(this.settings.getSettings());
if (this.isStopped) {
this.resolveDisconnectingPromise?.();
this.resolveDisconnectingPromise = null;
} else {
this.reconnectTimeoutId = setTimeout(() => {
this.reconnectTimeoutId = undefined;
this.initializeWebSocket();
}, this.settings.getSettings().webSocketRetryIntervalMs);
}
};
@ -159,37 +267,31 @@ export class WebSocketManager {
message: WebSocketServerMessage
): Promise<void> {
if (message.type === "vaultUpdate") {
try {
await Promise.all(
message.documents.map(async (document) =>
this.syncer.syncRemotelyUpdatedFile(document)
)
);
await awaitAll(
this.remoteVaultUpdateListeners.map(async (listener) => {
await listener(message).catch((error: unknown) => {
this.logger.error(
`Error in vault update listener: ${String(error)}`
);
});
})
);
if (message.isInitialSync && message.documents.length > 0) {
this.database.setLastSeenUpdateId(
message.documents
.map((document) => document.vaultUpdateId)
.reduce((a, b) => Math.max(a, b))
);
}
this._isFirstSyncCompleted = true;
} catch (e) {
this.logger.error(`Failed to sync remotely updated file: ${e}`);
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (message.type === "cursorPositions") {
this.logger.debug(
`Received cursor positions for ${JSON.stringify(message.clients)}`
);
this.remoteCursorsUpdateListeners.forEach((listener) => {
listener(
message.clients.filter(
(client) => client.deviceId !== this.deviceId
)
);
});
await awaitAll(
this.remoteCursorsUpdateListeners.map(async (listener) => {
await listener(message.clients).catch((error: unknown) => {
this.logger.error(
`Error in cursor positions listener: ${String(error)}`
);
});
})
);
} else {
this.logger.warn(
`Received unknown message type: ${JSON.stringify(message)}`

View file

@ -1,16 +1,17 @@
import type { PersistenceProvider } from "./persistence/persistence";
import type { HistoryEntry, HistoryStats } from "./tracing/sync-history";
import { SyncHistory } from "./tracing/sync-history";
import { Logger } from "./tracing/logger";
import { Logger, LogLevel, LogLine } from "./tracing/logger";
import type { RelativePath, StoredDatabase } from "./persistence/database";
import { Database } from "./persistence/database";
import * as Sentry from "@sentry/browser";
import type { SyncSettings } from "./persistence/settings";
import { Settings } from "./persistence/settings";
import { DEFAULT_SETTINGS, Settings } from "./persistence/settings";
import { SyncService } from "./services/sync-service";
import { Syncer } from "./sync-operations/syncer";
import type { FileSystemOperations } from "./file-operations/filesystem-operations";
import { FileOperations } from "./file-operations/file-operations";
import { ConnectionStatus } from "./services/connection-status";
import { FetchController } from "./services/fetch-controller";
import { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer";
import { rateLimit } from "./utils/rate-limit";
import type { NetworkConnectionStatus } from "./types/network-connection-status";
@ -21,13 +22,16 @@ import { CursorTracker } from "./sync-operations/cursor-tracker";
import type { CursorSpan } from "./services/types/CursorSpan";
import type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
import { FileChangeNotifier } from "./sync-operations/file-change-notifier";
import { FixedSizeDocumentCache } from "./utils/fix-sized-cache";
import { FixedSizeDocumentCache } from "./utils/data-structures/fix-sized-cache";
import { setUpTelemetry } from "./utils/set-up-telemetry";
import { DIFF_CACHE_SIZE_MB } from "./consts";
import { ServerConfig } from "./services/server-config";
export class SyncClient {
private static readonly MINIMUM_SAVE_INTERVAL_MS = 1000;
private hasStartedOfflineSync = false;
private hasFinishedOfflineSync = false;
private hasStarted = false;
private hasBeenDestroyed = false;
private unloadTelemetry?: () => void;
private constructor(
@ -35,56 +39,21 @@ export class SyncClient {
private readonly settings: Settings,
private readonly database: Database,
private readonly syncer: Syncer,
private readonly syncService: SyncService,
private readonly webSocketManager: WebSocketManager,
private readonly _logger: Logger,
private readonly connectionStatus: ConnectionStatus,
public readonly logger: Logger,
private readonly fetchController: FetchController,
private readonly cursorTracker: CursorTracker,
private readonly fileChangeNotifier: FileChangeNotifier,
private readonly contentCache: FixedSizeDocumentCache
) {
if (settings.getSettings().enableTelemetry) {
this.unloadTelemetry = setUpTelemetry();
}
this.settings.addOnSettingsChangeListener(
async (newSettings, oldSettings) => {
if (newSettings.vaultName !== oldSettings.vaultName) {
await this.reset();
}
if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) {
if (newSettings.isSyncEnabled) {
await this.start();
} else {
this.stop();
}
}
if (
newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB
) {
this.contentCache.resize(
newSettings.diffCacheSizeMB * 1024 * 1024
);
}
if (
newSettings.enableTelemetry !== oldSettings.enableTelemetry
) {
if (newSettings.enableTelemetry) {
this.unloadTelemetry = setUpTelemetry();
} else {
this.unloadTelemetry?.();
}
}
}
);
}
public get logger(): Logger {
return this._logger;
}
private readonly contentCache: FixedSizeDocumentCache,
private readonly fileOperations: FileOperations,
private readonly serverConfig: ServerConfig,
private readonly persistence: PersistenceProvider<
Partial<{
settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>;
}>
>
) {}
public get documentCount(): number {
return this.database.length;
@ -93,7 +62,6 @@ export class SyncClient {
public get isWebSocketConnected(): boolean {
return this.webSocketManager.isWebSocketConnected;
}
public static async create({
fs,
persistence,
@ -116,7 +84,7 @@ export class SyncClient {
const deviceId = createClientId();
logger.info(`Initialising SyncClient with client id ${deviceId}`);
logger.info(`Creating SyncClient with client id ${deviceId}`);
const history = new SyncHistory(logger);
@ -125,9 +93,20 @@ export class SyncClient {
database: undefined
};
const settings = new Settings(
logger,
state.settings,
async (data): Promise<void> => {
state = { ...state, settings: data };
// we're not rate-limiting settings saves as (1) we need to initialise the settings to know the rate limit
// and (2) settings changes are infrequent enough that rate-limiting is not necessary
await persistence.save(state);
}
);
const rateLimitedSave = rateLimit(
persistence.save,
SyncClient.MINIMUM_SAVE_INTERVAL_MS
() => settings.getSettings().minimumSaveIntervalMs
);
const database = new Database(
@ -139,32 +118,37 @@ export class SyncClient {
}
);
const settings = new Settings(
logger,
state.settings,
async (data): Promise<void> => {
state = { ...state, settings: data };
await rateLimitedSave(state);
}
const fetchController = new FetchController(
settings.getSettings().isSyncEnabled,
logger
);
settings.addOnSettingsChangeListener((newSettings, oldSettings) => {
if (oldSettings.isSyncEnabled != newSettings.isSyncEnabled) {
fetchController.canFetch = newSettings.isSyncEnabled;
}
});
const connectionStatus = new ConnectionStatus(settings, logger);
const syncService = new SyncService(
deviceId,
connectionStatus,
fetchController,
settings,
logger,
fetch
);
const serverConfig = new ServerConfig(syncService);
const fileOperations = new FileOperations(
logger,
database,
fs,
serverConfig,
nativeLineEndings
);
const contentCache = new FixedSizeDocumentCache(1024 * 1024 * 2); // 2 MB cache
const contentCache = new FixedSizeDocumentCache(
1024 * 1024 * DIFF_CACHE_SIZE_MB
);
const unrestrictedSyncer = new UnrestrictedSyncer(
logger,
database,
@ -172,25 +156,26 @@ export class SyncClient {
syncService,
fileOperations,
history,
contentCache
);
const syncer = new Syncer(
logger,
database,
settings,
syncService,
fileOperations,
unrestrictedSyncer
contentCache,
serverConfig
);
const webSocketManager = new WebSocketManager(
deviceId,
logger,
settings,
webSocket
);
const syncer = new Syncer(
deviceId,
logger,
database,
settings,
syncer,
webSocket
syncService,
webSocketManager,
fileOperations,
unrestrictedSyncer
);
const fileChangeNotifier = new FileChangeNotifier();
@ -205,22 +190,78 @@ export class SyncClient {
settings,
database,
syncer,
syncService,
webSocketManager,
logger,
connectionStatus,
fetchController,
cursorTracker,
fileChangeNotifier,
contentCache
contentCache,
fileOperations,
serverConfig,
persistence
);
logger.info("SyncClient initialised");
logger.info("SyncClient created successfully");
return client;
}
public async start(): Promise<void> {
this.checkIfDestroyed("start");
if (this.hasStarted) {
throw new Error("SyncClient has already been started");
}
this.hasStarted = true;
if (
!this.unloadTelemetry &&
this.settings.getSettings().enableTelemetry
) {
this.unloadTelemetry = setUpTelemetry();
}
this.logger.addOnMessageListener((log): void => {
if (log.level === LogLevel.ERROR && Sentry.isInitialized()) {
Sentry.captureMessage(log.message);
}
});
this.settings.addOnSettingsChangeListener(
this.onSettingsChange.bind(this)
);
if (this.settings.getSettings().isSyncEnabled) {
this.logger.info("Starting SyncClient");
await this.startSyncing();
this.logger.info("SyncClient has successfully started");
}
}
/**
* Reload settings from disk overriding current in-memory settings.
* Missing values will be filled in from DEFAULT_SETTINGS rather than
* retaining current in-memory settings.
*/
public async reloadSettings(): Promise<void> {
this.checkIfDestroyed("reloadSettings");
const state = (await this.persistence.load()) ?? {
settings: undefined
};
const settings = {
...DEFAULT_SETTINGS,
...(state.settings ?? {})
};
await this.setSettings(settings);
}
public async checkConnection(): Promise<NetworkConnectionStatus> {
const server = await this.syncService.checkConnection();
this.checkIfDestroyed("checkConnection");
const server = await this.serverConfig.checkConnection(true);
return {
isSuccessful: server.isSuccessful,
serverMessage: server.message,
@ -235,42 +276,34 @@ export class SyncClient {
public addSyncHistoryUpdateListener(
listener: (stats: HistoryStats) => unknown
): void {
this.checkIfDestroyed("addSyncHistoryUpdateListener");
this.history.addSyncHistoryUpdateListener(listener);
}
public async start(): Promise<void> {
if (!this.hasStartedOfflineSync) {
await this.syncer.scheduleSyncForOfflineChanges();
this.hasStartedOfflineSync = true;
}
this.hasFinishedOfflineSync = true;
this.webSocketManager.start();
}
public stop(): void {
this.hasFinishedOfflineSync = false;
this.webSocketManager.stop();
}
public async waitAndStop(): Promise<void> {
this.stop();
await this.syncer.waitUntilFinished();
}
/// Wait for the in-flight operations to finish, reset all tracking,
/// and the local database but retain the settings.
/// The SyncClient can be used again after calling this method.
/**
* Wait for the in-flight operations to finish, reset all tracking,
* and the local database but retain the settings.
* The SyncClient can be used again after calling this method.
*/
public async reset(): Promise<void> {
this.stop();
this.connectionStatus.startReset();
this.contentCache.clear();
await this.syncer.reset();
this.history.reset();
this.checkIfDestroyed("reset");
this.logger.info(
"Stopping SyncClient to apply changed connection settings"
);
await this.pause();
// clear all local state
this.logger.info("Resetting SyncClient's local state");
this.database.reset();
this._logger.reset();
this.connectionStatus.finishReset();
await this.start();
await this.database.save(); // ensure the new database reads as empty
this.resetInMemoryState();
this.hasStartedOfflineSync = false;
this.hasFinishedOfflineSync = false;
this.serverConfig.reset();
await this.startSyncing();
}
public getSettings(): SyncSettings {
@ -281,32 +314,44 @@ export class SyncClient {
key: T,
value: SyncSettings[T]
): Promise<void> {
this.checkIfDestroyed("setSetting");
await this.settings.setSetting(key, value);
}
public async setSettings(value: Partial<SyncSettings>): Promise<void> {
this.checkIfDestroyed("setSettings");
await this.settings.setSettings(value);
}
public addOnSettingsChangeListener(
handler: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
listener: (settings: SyncSettings, oldSettings: SyncSettings) => unknown
): void {
this.settings.addOnSettingsChangeListener(handler);
this.checkIfDestroyed("addOnSettingsChangeListener");
this.settings.addOnSettingsChangeListener(listener);
}
public addRemainingSyncOperationsListener(
listener: (remainingOperations: number) => unknown
): void {
this.checkIfDestroyed("addRemainingSyncOperationsListener");
this.syncer.addRemainingOperationsListener(listener);
}
public addWebSocketStatusChangeListener(listener: () => unknown): void {
this.checkIfDestroyed("addWebSocketStatusChangeListener");
this.webSocketManager.addWebSocketStatusChangeListener(listener);
}
public async syncLocallyCreatedFile(
relativePath: RelativePath
): Promise<void> {
this.checkIfDestroyed("syncLocallyCreatedFile");
this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyCreatedFile(relativePath);
}
@ -314,6 +359,8 @@ export class SyncClient {
public async syncLocallyDeletedFile(
relativePath: RelativePath
): Promise<void> {
this.checkIfDestroyed("syncLocallyDeletedFile");
this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyDeletedFile(relativePath);
}
@ -325,6 +372,8 @@ export class SyncClient {
oldPath?: RelativePath;
relativePath: RelativePath;
}): Promise<void> {
this.checkIfDestroyed("syncLocallyUpdatedFile");
this.fileChangeNotifier.notifyOfFileChange(relativePath);
return this.syncer.syncLocallyUpdatedFile({
oldPath,
@ -335,14 +384,13 @@ export class SyncClient {
public getDocumentSyncingStatus(
relativePath: RelativePath
): DocumentSyncStatus {
this.checkIfDestroyed("getDocumentSyncingStatus");
if (!this.settings.getSettings().isSyncEnabled) {
return DocumentSyncStatus.SYNCING_IS_DISABLED;
}
if (
!this.webSocketManager.isFirstSyncCompleted ||
!this.hasFinishedOfflineSync
) {
if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) {
return DocumentSyncStatus.SYNCING;
}
@ -359,12 +407,114 @@ export class SyncClient {
public async updateLocalCursors(
documentToCursors: Record<RelativePath, CursorSpan[]>
): Promise<void> {
this.checkIfDestroyed("updateLocalCursors");
await this.cursorTracker.sendLocalCursorsToServer(documentToCursors);
}
public addRemoteCursorsUpdateListener(
listener: (cursors: MaybeOutdatedClientCursors[]) => unknown
): void {
this.checkIfDestroyed("addRemoteCursorsUpdateListener");
this.cursorTracker.addRemoteCursorsUpdateListener(listener);
}
public async waitUntilFinished(): Promise<void> {
this.checkIfDestroyed("waitUntilIdle");
await this.syncer.waitUntilFinished();
await this.webSocketManager.waitUntilFinished();
await this.database.save(); // flush all changes to disk
}
/**
* Completely destroy the SyncClient, cancelling all in-progress operations.
* After calling this method, the SyncClient cannot be used again.
*/
public async destroy(): Promise<void> {
this.checkIfDestroyed("destroy");
// cancel everything that's in progress
await this.pause();
this.hasBeenDestroyed = true;
this.resetInMemoryState();
this.logger.info("SyncClient has been successfully disposed");
this.unloadTelemetry?.();
}
private async startSyncing(): Promise<void> {
this.checkIfDestroyed("startSyncing");
this.fetchController.finishReset();
await this.serverConfig.initialize();
this.webSocketManager.start();
if (!this.hasStartedOfflineSync) {
this.hasStartedOfflineSync = true;
await this.syncer.scheduleSyncForOfflineChanges();
}
this.hasFinishedOfflineSync = true;
}
private async pause(): Promise<void> {
this.fetchController.startReset();
await this.webSocketManager.stop();
await this.waitUntilFinished();
}
private resetInMemoryState(): void {
this.history.reset();
this.contentCache.reset();
// don't reset the logger
this.cursorTracker.reset();
this.syncer.reset();
this.fileOperations.reset();
}
private async onSettingsChange(
newSettings: SyncSettings,
oldSettings: SyncSettings
): Promise<void> {
this.checkIfDestroyed("onSettingsChange");
if (
newSettings.vaultName !== oldSettings.vaultName ||
newSettings.remoteUri !== oldSettings.remoteUri
) {
await this.reset();
}
if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) {
if (newSettings.isSyncEnabled) {
await this.startSyncing();
} else {
await this.pause();
}
}
if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) {
this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024);
}
if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) {
if (newSettings.enableTelemetry) {
this.unloadTelemetry = setUpTelemetry();
} else {
this.unloadTelemetry?.();
}
}
}
private checkIfDestroyed(origin: string): void {
if (this.hasBeenDestroyed) {
throw new Error(
`SyncClient has been destroyed and can no longer be used; called from ${origin}`
);
}
}
}

View file

@ -8,7 +8,7 @@ import type { MaybeOutdatedClientCursors } from "../types/maybe-outdated-client-
import { DocumentUpToDateness } from "../types/document-up-to-dateness";
import { hash } from "../utils/hash";
import type { FileChangeNotifier } from "./file-change-notifier";
import { Lock } from "../utils/locks";
import { Lock } from "../utils/data-structures/locks";
// Cursor positions are updated separately from documents. However, a given cursor position is only
// valid within a certain version of the document it belongs to. This class tracks previous and the latest
@ -157,6 +157,13 @@ export class CursorTracker {
});
}
public reset(): void {
this.knownRemoteCursors = [];
this.lastLocalCursorState = [];
this.lastLocalCursorStateWithoutDirtyDocuments = [];
this.updateLock.reset();
}
private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] {
const result: MaybeOutdatedClientCursors[] = [];
const included = new Set<string>();
@ -167,14 +174,14 @@ export class CursorTracker {
continue;
}
if (clientCursors.upToDateness == DocumentUpToDateness.Later) {
if (clientCursors.upToDateness === DocumentUpToDateness.Later) {
continue;
}
result.push({
...clientCursors,
isOutdated:
clientCursors.upToDateness == DocumentUpToDateness.Prior
clientCursors.upToDateness === DocumentUpToDateness.Prior
});
included.add(clientCursors.deviceId);

View file

@ -9,6 +9,15 @@ export class FileChangeNotifier {
this.listeners.push(listener);
}
public removeFileChangeListener(
listener: (filePath: RelativePath) => unknown
): void {
const index = this.listeners.indexOf(listener);
if (index !== -1) {
this.listeners.splice(index, 1);
}
}
public notifyOfFileChange(filePath: RelativePath): void {
this.listeners.forEach((listener) => listener(filePath));
}

View file

@ -15,24 +15,32 @@ import { findMatchingFile } from "../utils/find-matching-file";
import type { UnrestrictedSyncer } from "./unrestricted-syncer";
import { createPromise } from "../utils/create-promise";
import { SyncResetError } from "../services/sync-reset-error";
import { Locks } from "../utils/locks";
import { Locks } from "../utils/data-structures/locks";
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
import type { FixedSizeDocumentCache } from "../utils/fix-sized-cache";
import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate";
import type { WebSocketManager } from "../services/websocket-manager";
import type { WebSocketClientMessage } from "../services/types/WebSocketClientMessage";
import { awaitAll } from "../utils/await-all";
export class Syncer {
private readonly remoteDocumentsLock: Locks<DocumentId>;
private readonly remainingOperationsListeners: ((
remainingOperations: number
) => unknown)[] = [];
// FIFO to limit the number of concurrent sync operations
private readonly syncQueue: PQueue;
private _isFirstSyncComplete = false;
private runningScheduleSyncForOfflineChanges: Promise<void> | undefined;
public constructor(
private readonly deviceId: string,
private readonly logger: Logger,
private readonly database: Database,
settings: Settings,
private readonly settings: Settings,
private readonly syncService: SyncService,
private readonly webSocketManager: WebSocketManager,
private readonly operations: FileOperations,
private readonly internalSyncer: UnrestrictedSyncer
) {
@ -53,6 +61,22 @@ export class Syncer {
listener(this.syncQueue.size);
});
});
this.webSocketManager.addWebSocketStatusChangeListener(
(isConnected) => {
if (isConnected) {
// The JS WebSocket API doesn't support setting headers, so we have to send the token as a message
this.sendHandshakeMessage();
}
}
);
this.webSocketManager.addRemoteVaultUpdateListener(
this.syncRemotelyUpdatedFile.bind(this)
);
}
public get isFirstSyncComplete(): boolean {
return this._isFirstSyncComplete;
}
public addRemainingOperationsListener(
@ -83,10 +107,6 @@ export class Syncer {
promise
);
this.logger.debug(
`Creating new pending document ${relativePath} with id ${id}`
);
try {
await this.syncQueue.add(async () =>
this.internalSyncer.unrestrictedSyncLocallyCreatedFile(document)
@ -246,14 +266,53 @@ export class Syncer {
public async waitUntilFinished(): Promise<void> {
await this.runningScheduleSyncForOfflineChanges;
return this.syncQueue.onEmpty();
}
public async reset(): Promise<void> {
await this.waitUntilFinished();
await this.syncQueue.onEmpty();
}
public async syncRemotelyUpdatedFile(
message: WebSocketVaultUpdate
): Promise<void> {
try {
const handlerPromise = awaitAll(
message.documents.map(async (document) =>
this.internalSyncRemotelyUpdatedFile(document)
)
);
await handlerPromise;
if (message.isInitialSync && message.documents.length > 0) {
this.database.setLastSeenUpdateId(
message.documents
.map((document) => document.vaultUpdateId)
.reduce((a, b) => Math.max(a, b))
);
}
this._isFirstSyncComplete = true;
} catch (e) {
this.logger.error(`Failed to sync remotely updated file: ${e}`);
}
}
public reset(): void {
this._isFirstSyncComplete = false;
this.syncQueue.clear();
this.remoteDocumentsLock.reset();
this.runningScheduleSyncForOfflineChanges = undefined;
}
private sendHandshakeMessage(): void {
const message: WebSocketClientMessage = {
type: "handshake",
deviceId: this.deviceId,
token: this.settings.getSettings().token,
lastSeenVaultUpdateId: this.database.getLastSeenUpdateId()
};
this.webSocketManager.sendHandshakeMessage(message);
}
private async internalSyncRemotelyUpdatedFile(
remoteVersion: DocumentVersionWithoutContent
): Promise<void> {
let document = this.database.getDocumentByDocumentId(
@ -337,6 +396,9 @@ export class Syncer {
await this.createFakeDocumentsFromRemoteState();
const allLocalFiles = await this.operations.listFilesRecursively();
this.logger.info(
`Scheduling sync for ${allLocalFiles.length} local files`
);
let locallyPossiblyDeletedFiles: DocumentRecord[] = [];
@ -349,7 +411,7 @@ export class Syncer {
}
}
const updates = Promise.all(
await awaitAll(
allLocalFiles.map(async (relativePath) => {
if (
this.database.getLatestDocumentByRelativePath(relativePath)
@ -407,7 +469,9 @@ export class Syncer {
})
);
const deletes = Promise.all(
// this has to happen strictly after the previous awaitAll, as that one
// might have removed some of the documents from the list
await awaitAll(
locallyPossiblyDeletedFiles.map(async ({ relativePath }) => {
this.logger.debug(
`Document ${relativePath} has been deleted locally, scheduling sync to delete it`
@ -417,8 +481,6 @@ export class Syncer {
return this.syncLocallyDeletedFile(relativePath);
})
);
await Promise.all([updates, deletes]);
}
/**
@ -431,7 +493,7 @@ export class Syncer {
return;
}
const [allLocalFiles, remote] = await Promise.all([
const [allLocalFiles, remote] = await awaitAll([
this.operations.listFilesRecursively(),
this.syncQueue.add(async () => this.syncService.getAll())
]);

View file

@ -18,7 +18,8 @@ import type {
} from "../tracing/sync-history";
import { SyncStatus, SyncType } from "../tracing/sync-history";
import { EMPTY_HASH, hash } from "../utils/hash";
import { deserialize } from "../utils/deserialize";
import { base64ToBytes } from "byte-base64";
import type { Settings } from "../persistence/settings";
import type { FileOperations } from "../file-operations/file-operations";
import { createPromise } from "../utils/create-promise";
@ -28,9 +29,10 @@ import { globsToRegexes } from "../utils/globs-to-regexes";
import type { DocumentVersion } from "../services/types/DocumentVersion";
import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse";
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
import type { FixedSizeDocumentCache } from "../utils/fix-sized-cache";
import type { FixedSizeDocumentCache } from "../utils/data-structures/fix-sized-cache";
import { isFileTypeMergable } from "../utils/is-file-type-mergable";
import { isBinary } from "../utils/is-binary";
import type { ServerConfig } from "../services/server-config";
export class UnrestrictedSyncer {
private ignorePatterns: RegExp[];
@ -42,7 +44,8 @@ export class UnrestrictedSyncer {
private readonly syncService: SyncService,
private readonly operations: FileOperations,
private readonly history: SyncHistory,
private readonly contentCache: FixedSizeDocumentCache
private readonly contentCache: FixedSizeDocumentCache,
private readonly serverConfig: ServerConfig
) {
this.ignorePatterns = globsToRegexes(
this.settings.getSettings().ignorePatterns,
@ -66,24 +69,35 @@ export class UnrestrictedSyncer {
};
return this.executeSync(updateDetails, async () => {
const originalRelativePath = document.relativePath;
if (document.isDeleted) {
this.logger.debug(
`Document ${document.relativePath} has been already deleted, no need to create it`
`Document ${originalRelativePath} has been already deleted, no need to create it`
);
return;
}
const contentBytes = await this.operations.read(
document.relativePath
); // this can throw FileNotFoundError
const contentBytes =
await this.operations.read(originalRelativePath); // this can throw FileNotFoundError
const contentHash = hash(contentBytes);
const response = await this.syncService.create({
documentId: document.documentId,
relativePath: document.relativePath,
relativePath: originalRelativePath,
contentBytes
});
// In case a document with the same name (but different ID) had existed remotely that we haven't known about
if (response.relativePath != originalRelativePath) {
this.logger.debug(
`Document ${originalRelativePath} has been created remotely at a different path: ${response.relativePath}, moving it locally`
);
await this.operations.move(
document.relativePath,
response.relativePath
); // this can throw FileNotFoundError
}
this.database.updateDocumentMetadata(
{
parentVersionId: response.vaultUpdateId,
@ -92,6 +106,7 @@ export class UnrestrictedSyncer {
},
document
);
this.database.addSeenUpdateId(response.vaultUpdateId);
this.updateCache(
response.vaultUpdateId,
@ -189,7 +204,10 @@ export class UnrestrictedSyncer {
if (areThereLocalChanges) {
const isText =
!isBinary(contentBytes) &&
isFileTypeMergable(document.relativePath);
isFileTypeMergable(
document.relativePath,
this.serverConfig.getConfig().mergeableFileExtensions
);
const cachedVersion = this.contentCache.get(
document.metadata.parentVersionId
);
@ -249,33 +267,7 @@ export class UnrestrictedSyncer {
}
if (response.isDeleted) {
this.history.addHistoryEntry({
status: SyncStatus.SUCCESS,
details: {
type: SyncType.DELETE,
relativePath: document.relativePath
},
message:
"File has been deleted remotely, so we deleted it locally",
author: response.userId,
timestamp: new Date(response.updatedDate)
});
this.database.delete(document.relativePath);
this.database.updateDocumentMetadata(
{
parentVersionId: response.vaultUpdateId,
hash: EMPTY_HASH,
remoteRelativePath: response.relativePath
},
document
);
await this.operations.delete(document.relativePath);
this.database.addSeenUpdateId(response.vaultUpdateId);
return;
return this.applyRemoteDeleteLocally(document, response);
}
let actualPath = document.relativePath;
@ -292,7 +284,7 @@ export class UnrestrictedSyncer {
}
if (!("type" in response) || response.type === "MergingUpdate") {
const responseBytes = deserialize(response.contentBase64);
const responseBytes = base64ToBytes(response.contentBase64);
contentHash = hash(responseBytes);
this.database.updateDocumentMetadata(
@ -439,7 +431,7 @@ export class UnrestrictedSyncer {
return;
}
const contentBytes = deserialize(content);
const contentBytes = base64ToBytes(content);
await this.operations.ensureClearPath(remoteVersion.relativePath);
@ -562,8 +554,44 @@ export class UnrestrictedSyncer {
contentBytes: Uint8Array,
filePath: RelativePath
): void {
if (isFileTypeMergable(filePath) && !isBinary(contentBytes)) {
if (
isFileTypeMergable(
filePath,
this.serverConfig.getConfig().mergeableFileExtensions
) &&
!isBinary(contentBytes)
) {
this.contentCache.put(updateId, contentBytes);
}
}
private async applyRemoteDeleteLocally(
document: DocumentRecord,
response: DocumentVersion | DocumentUpdateResponse
): 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.updateDocumentMetadata(
{
parentVersionId: response.vaultUpdateId,
hash: EMPTY_HASH,
remoteRelativePath: response.relativePath
},
document
);
await this.operations.delete(document.relativePath);
this.database.addSeenUpdateId(response.vaultUpdateId);
}
}

View file

@ -1,3 +1,5 @@
import { MAX_LOG_MESSAGE_COUNT } from "../consts";
export enum LogLevel {
DEBUG = "DEBUG",
INFO = "INFO",
@ -21,7 +23,6 @@ export class LogLine {
}
export class Logger {
private static readonly MAX_MESSAGES = 100000;
private readonly messages: LogLine[] = [];
private readonly onMessageListeners: ((message: LogLine) => unknown)[] = [];
@ -59,6 +60,15 @@ export class Logger {
this.onMessageListeners.push(listener);
}
public removeOnMessageListener(
listener: (message: LogLine) => unknown
): void {
const index = this.onMessageListeners.indexOf(listener);
if (index !== -1) {
this.onMessageListeners.splice(index, 1);
}
}
public reset(): void {
this.messages.length = 0;
this.debug("Logger has been reset");
@ -68,7 +78,7 @@ export class Logger {
const logLine = new LogLine(level, message);
this.messages.push(logLine);
while (this.messages.length > Logger.MAX_MESSAGES) {
while (this.messages.length > MAX_LOG_MESSAGE_COUNT) {
this.messages.shift();
}

View file

@ -1,3 +1,7 @@
import {
MAX_HISTORY_ENTRY_COUNT,
TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS
} from "../consts";
import type { RelativePath } from "../persistence/database";
import type { Logger } from "./logger";
@ -64,9 +68,6 @@ export interface HistoryStats {
}
export class SyncHistory {
private static readonly MAX_ENTRIES = 5000;
private static readonly TIMEOUT_FOR_MERGING_ENTRIES_IN_SECONDS = 60;
private _entries: HistoryEntry[] = [];
private readonly syncHistoryUpdateListeners: ((
@ -104,7 +105,7 @@ export class SyncHistory {
// Insert the entry at the beginning
this._entries.unshift(historyEntry);
if (this._entries.length > SyncHistory.MAX_ENTRIES) {
if (this._entries.length > MAX_HISTORY_ENTRY_COUNT) {
this._entries.pop();
}
@ -118,6 +119,15 @@ export class SyncHistory {
listener({ ...this.status });
}
public removeSyncHistoryUpdateListener(
listener: (stats: HistoryStats) => unknown
): void {
const index = this.syncHistoryUpdateListeners.indexOf(listener);
if (index !== -1) {
this.syncHistoryUpdateListeners.splice(index, 1);
}
}
public reset(): void {
this._entries.length = 0;
this.status = {
@ -145,7 +155,7 @@ export class SyncHistory {
candidate !== undefined &&
(this._entries[0] === candidate ||
candidate.timestamp.getTime() +
SyncHistory.TIMEOUT_FOR_MERGING_ENTRIES_IN_SECONDS * 1000 >
TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS * 1000 >
entry.timestamp.getTime())
) {
return candidate;
@ -164,7 +174,7 @@ export class SyncHistory {
this.logger.error(`Cannot sync file: ${message}`);
break;
case SyncStatus.SKIPPED:
this.logger.error(`Skipping file: ${message}`);
this.logger.warn(`Skipping file: ${message}`);
break;
}

View file

@ -0,0 +1,56 @@
import { test } from "node:test";
import assert from "node:assert";
import { awaitAll } from "./await-all";
void test("awaitAll resolves promises of the same type", async () => {
const promises = [
Promise.resolve(1),
Promise.resolve(2),
Promise.resolve(3)
];
const results = await awaitAll(promises);
assert.deepStrictEqual(results, [1, 2, 3]);
});
void test("awaitAll resolves promises of different types", async () => {
const promises = [
Promise.resolve("hello"),
Promise.resolve(42),
Promise.resolve(true)
] as const;
const results = await awaitAll(promises);
// Type assertions to verify type inference
const str: string = results[0];
const num: number = results[1];
const bool: boolean = results[2];
assert.strictEqual(str, "hello");
assert.strictEqual(num, 42);
assert.strictEqual(bool, true);
});
void test("awaitAll throws on first rejection", async () => {
const error = new Error("Test error");
const promises = [
Promise.resolve(1),
Promise.reject(error),
Promise.resolve(3)
];
await assert.rejects(async () => {
await awaitAll(promises);
}, error);
});
void test("awaitAll works with async functions", async () => {
const asyncString = async (): Promise<string> => "async";
const asyncNumber = async (): Promise<number> => 123;
const results = await awaitAll([asyncString(), asyncNumber()]);
assert.strictEqual(results[0], "async");
assert.strictEqual(results[1], 123);
});

View file

@ -0,0 +1,25 @@
type PromiseTuple<T extends readonly unknown[]> = readonly [
...{ [K in keyof T]: Promise<T[K]> }
];
type ResolvedTuple<T extends readonly unknown[]> = {
[K in keyof T]: T[K];
};
export const awaitAll = async <T extends readonly unknown[]>(
promises: PromiseTuple<T>
): Promise<ResolvedTuple<T>> => {
// eslint-disable-next-line no-restricted-properties
const result = await Promise.allSettled(promises);
for (const res of result) {
if (res.status === "rejected") {
throw res.reason;
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return result.map(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(res) => (res as PromiseFulfilledResult<unknown>).value
) as ResolvedTuple<T>;
};

View file

@ -89,7 +89,7 @@ describe("fixedSizeDocumentCache", () => {
assert.equal(cache.get(1), doc1);
assert.equal(cache.get(2), doc2);
cache.clear();
cache.reset();
assert.equal(cache.get(1), undefined);
assert.equal(cache.get(2), undefined);

View file

@ -1,6 +1,6 @@
// Implements an in-memory fixed-size cache for document contents,
import type { VaultUpdateId } from "../persistence/database";
import type { VaultUpdateId } from "../../persistence/database";
// Doubly-linked list node for O(1) LRU operations
class LRUNode {
@ -57,7 +57,7 @@ export class FixedSizeDocumentCache {
this.fitBelowMaxSize();
}
public clear(): void {
public reset(): void {
this.cache.clear();
this.head = null;
this.tail = null;

View file

@ -1,8 +1,10 @@
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert";
import { Logger } from "../tracing/logger";
import type { RelativePath } from "../persistence/database";
import { Logger } from "../../tracing/logger";
import type { RelativePath } from "../../persistence/database";
import { Locks } from "./locks";
import { awaitAll } from "../await-all";
import { sleep } from "../sleep";
describe("withLock", () => {
const testPath: RelativePath = "test/document/path";
@ -31,7 +33,7 @@ describe("withLock", () => {
let executionCount = 0;
const result = await locks.withLock(testPath, async () => {
executionCount++;
await new Promise((resolve) => setTimeout(resolve, 10));
await sleep(10);
return "async-success";
});
@ -56,19 +58,19 @@ describe("withLock", () => {
// Start two concurrent operations with keys in different orders
const promise1 = locks.withLock([testPath2, testPath], async () => {
executionOrder.push("operation1-start");
await new Promise((resolve) => setTimeout(resolve, 50));
await sleep(50);
executionOrder.push("operation1-end");
return "result1";
});
const promise2 = locks.withLock([testPath, testPath2], async () => {
executionOrder.push("operation2-start");
await new Promise((resolve) => setTimeout(resolve, 50));
await sleep(50);
executionOrder.push("operation2-end");
return "result2";
});
const [result1, result2] = await Promise.all([promise1, promise2]);
const [result1, result2] = await awaitAll([promise1, promise2]);
assert.strictEqual(result1, "result1");
assert.strictEqual(result2, "result2");
@ -86,19 +88,19 @@ describe("withLock", () => {
const promise1 = locks.withLock(testPath, async () => {
executionOrder.push("operation1-start");
await new Promise((resolve) => setTimeout(resolve, 50));
await sleep(50);
executionOrder.push("operation1-end");
return "result1";
});
const promise2 = locks.withLock(testPath, async () => {
executionOrder.push("operation2-start");
await new Promise((resolve) => setTimeout(resolve, 30));
await sleep(30);
executionOrder.push("operation2-end");
return "result2";
});
const [result1, result2] = await Promise.all([promise1, promise2]);
const [result1, result2] = await awaitAll([promise1, promise2]);
assert.strictEqual(result1, "result1");
assert.strictEqual(result2, "result2");
@ -115,19 +117,20 @@ describe("withLock", () => {
const promise1 = locks.withLock(testPath, async () => {
executionOrder.push("operation1-start");
await new Promise((resolve) => setTimeout(resolve, 50));
await sleep(50);
executionOrder.push("operation1-end");
return "result1";
});
const promise2 = locks.withLock(testPath2, async () => {
executionOrder.push("operation2-start");
await new Promise((resolve) => setTimeout(resolve, 30));
await sleep(30);
executionOrder.push("operation2-end");
return "result2";
});
const [result1, result2] = await Promise.all([promise1, promise2]);
const [result1, result2] = await awaitAll([promise1, promise2]);
assert.strictEqual(result1, "result1");
assert.strictEqual(result2, "result2");
@ -159,7 +162,8 @@ describe("withLock", () => {
await assert.rejects(
locks.withLock(testPath, async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
await sleep(10);
throw error;
}),
{ message: "async test error" }
@ -184,30 +188,30 @@ describe("withLock", () => {
// Start first operation that holds the lock
const firstPromise = locks.withLock(testPath, async () => {
executionOrder.push("first-start");
await new Promise((resolve) => setTimeout(resolve, 100));
await sleep(100);
executionOrder.push("first-end");
return "first";
});
// Small delay to ensure first operation starts
await new Promise((resolve) => setTimeout(resolve, 10));
await sleep(10);
// Queue second and third operations
const secondPromise = locks.withLock(testPath, async () => {
executionOrder.push("second-start");
await new Promise((resolve) => setTimeout(resolve, 30));
await sleep(50);
executionOrder.push("second-end");
return "second";
});
const thirdPromise = locks.withLock(testPath, async () => {
executionOrder.push("third-start");
await new Promise((resolve) => setTimeout(resolve, 20));
await sleep(20);
executionOrder.push("third-end");
return "third";
});
const [first, second, third] = await Promise.all([
const [first, second, third] = await awaitAll([
firstPromise,
secondPromise,
thirdPromise

View file

@ -1,4 +1,5 @@
import type { Logger } from "../tracing/logger";
import type { Logger } from "../../tracing/logger";
import { awaitAll } from "../await-all";
/**
* Manages exclusive locks on items to prevent concurrent modifications.
@ -49,19 +50,27 @@ export class Locks<T> {
fn: () => R | Promise<R>
): Promise<R> {
const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys];
keys.sort((a, b) => String(a).localeCompare(String(b))); // Ensure consistent order to prevent deadlocks
await Promise.all(keys.map(async (key) => this.waitForLock(key)));
// Deduplicate keys to prevent deadlock from acquiring same lock twice
const uniqueKeys = Array.from(new Set(keys));
uniqueKeys.sort((a, b) => String(a).localeCompare(String(b))); // Ensure consistent order to prevent deadlocks
await awaitAll(uniqueKeys.map(async (key) => this.waitForLock(key)));
try {
return await fn();
} finally {
keys.forEach((key) => {
uniqueKeys.forEach((key) => {
this.unlock(key);
});
}
}
public reset(): void {
this.locked.clear();
this.waiters.clear();
}
/**
* Attempts to acquire a lock immediately without waiting.
* Must call `unlock()` if successful.
@ -69,7 +78,7 @@ export class Locks<T> {
* @param key The key to lock
* @returns `true` if lock acquired, `false` if already locked
*/
private tryLock(key: T): boolean {
public tryLock(key: T): boolean {
if (this.locked.has(key)) {
return false;
}
@ -86,7 +95,7 @@ export class Locks<T> {
* @param key The key to wait for and lock
* @returns Promise that resolves when lock is acquired
*/
private async waitForLock(key: T): Promise<void> {
public async waitForLock(key: T): Promise<void> {
if (this.tryLock(key)) {
return Promise.resolve();
}
@ -112,9 +121,12 @@ export class Locks<T> {
* @param key The key to unlock
* @throws {Error} If key is not currently locked
*/
private unlock(key: T): void {
public unlock(key: T): void {
if (!this.locked.has(key)) {
throw new Error(`Key '${key}' is not locked, cannot unlock`);
this.logger?.warn(
`Attempted to unlock key "${key}" which is not currently locked`
);
return;
}
// Remove first waiter to ensure FIFO order
@ -139,4 +151,8 @@ export class Lock {
public async withLock<R>(fn: () => R | Promise<R>): Promise<R> {
return this.locks.withLock(true, fn);
}
public reset(): void {
this.locks.reset();
}
}

View file

@ -48,15 +48,29 @@ describe("CoveredValues", () => {
assert.strictEqual(covered.min, 6);
});
it("should handle force setting min value", () => {
it("should auto-advance when setting min value", () => {
const covered = new CoveredValues(5);
covered.add(7);
covered.add(8);
covered.add(9);
assert.strictEqual(covered.min, 5);
// Setting min to 6 should auto-advance through 7, 8, 9
covered.min = 6;
assert.strictEqual(covered.min, 6);
assert.strictEqual(covered.min, 9);
covered.add(10);
assert.strictEqual(covered.min, 10);
});
it("should handle setting min value with no consecutive values", () => {
const covered = new CoveredValues(5);
covered.add(10);
covered.add(15);
assert.strictEqual(covered.min, 5);
// Setting min to 8 should not auto-advance (no consecutive values)
covered.min = 8;
assert.strictEqual(covered.min, 8);
// Add 9 to trigger auto-advance to 10
covered.add(9);
assert.strictEqual(covered.min, 10);
});
});

View file

@ -24,11 +24,12 @@ export class CoveredValues {
public set min(value: number) {
this.minValue = Math.max(value, this.minValue);
this.seenValues = this.seenValues.filter((v) => v > value);
this.seenValues = this.seenValues.filter((v) => v > this.minValue);
this.advanceMinWhilePossible();
}
public add(value: number): void {
if (value < this.minValue) {
public add(value: number | undefined): void {
if (value === undefined || value < this.minValue) {
return;
}
@ -45,6 +46,10 @@ export class CoveredValues {
this.seenValues.splice(i, 0, value);
}
this.advanceMinWhilePossible();
}
private advanceMinWhilePossible(): void {
while (
this.seenValues.length > 0 &&
this.seenValues[0] === this.minValue + 1

View file

@ -1,6 +1,6 @@
import type { SyncClient } from "../sync-client";
import type { LogLine } from "../tracing/logger";
import { LogLevel } from "../tracing/logger";
import type { SyncClient } from "../../sync-client";
import type { LogLine } from "../../tracing/logger";
import { LogLevel } from "../../tracing/logger";
export function logToConsole(client: SyncClient): void {
client.logger.addOnMessageListener((logLine: LogLine) => {

View file

@ -1,4 +1,4 @@
import { sleep } from "../utils/sleep";
import { sleep } from "../sleep";
export const slowFetchFactory =
(jitterScaleInSeconds: number) =>
@ -7,10 +7,14 @@ export const slowFetchFactory =
init?: RequestInit
): Promise<Response> => {
if (jitterScaleInSeconds > 0) {
await sleep(Math.random() * jitterScaleInSeconds * 1000);
await sleep(((Math.random() * jitterScaleInSeconds) / 2) * 1000);
}
const response = await fetch(input, init);
if (jitterScaleInSeconds > 0) {
await sleep(((Math.random() * jitterScaleInSeconds) / 2) * 1000);
}
return response;
};

View file

@ -1,29 +1,29 @@
import { sleep } from "../utils/sleep";
import { Locks } from "../utils/locks";
import type { Logger } from "../tracing/logger";
import { sleep } from "../sleep";
import { Locks } from "../data-structures/locks";
import type { Logger } from "../../tracing/logger";
export function slowWebSocketFactory(
jitterScaleInSeconds: number,
logger: Logger
): typeof WebSocket {
// eslint-disable-next-line
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return class FlakyWebSocket extends WebSocket {
private static readonly RECEIVE_KEY = "websocket-receive";
private static readonly SEND_KEY = "websocket-send";
private readonly locks = new Locks(logger);
public set onopen(callback: (event: Event) => void) {
public set onopen(callback: ((event: Event) => void) | null) {
super.onopen = async (event: Event): Promise<void> => {
if (jitterScaleInSeconds > 0) {
await sleep(Math.random() * jitterScaleInSeconds * 1000);
}
callback(event);
callback?.(event);
};
}
public set onmessage(callback: (event: MessageEvent) => void) {
public set onmessage(callback: ((event: MessageEvent) => void) | null) {
super.onmessage = async (event: MessageEvent): Promise<void> => {
await this.locks.withLock(
FlakyWebSocket.RECEIVE_KEY,
@ -34,27 +34,27 @@ export function slowWebSocketFactory(
);
}
callback(event);
callback?.(event);
}
);
};
}
public set onclose(callback: (event: CloseEvent) => void) {
public set onclose(callback: ((event: CloseEvent) => void) | null) {
super.onclose = async (event: CloseEvent): Promise<void> => {
if (jitterScaleInSeconds > 0) {
await sleep(Math.random() * jitterScaleInSeconds * 1000);
}
callback(event);
callback?.(event);
};
}
public set onerror(callback: (event: Event) => void) {
public set onerror(callback: ((event: Event) => void) | null) {
super.onerror = async (event: Event): Promise<void> => {
if (jitterScaleInSeconds > 0) {
await sleep(Math.random() * jitterScaleInSeconds * 1000);
}
callback(event);
callback?.(event);
};
}

View file

@ -1,5 +0,0 @@
import { base64ToBytes } from "byte-base64";
export function deserialize(data: string): Uint8Array {
return base64ToBytes(data);
}

View file

@ -1,29 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { isEqualBytes } from "./is-equal-bytes";
describe("isEqualBytes", () => {
it("should return true for equal byte arrays", () => {
const bytes1 = new Uint8Array([1, 2, 3, 4]);
const bytes2 = new Uint8Array([1, 2, 3, 4]);
assert.strictEqual(isEqualBytes(bytes1, bytes2), true);
});
it("should return false for byte arrays of different lengths", () => {
const bytes1 = new Uint8Array([1, 2, 3, 4]);
const bytes2 = new Uint8Array([1, 2, 3]);
assert.strictEqual(isEqualBytes(bytes1, bytes2), false);
});
it("should return true for empty byte arrays", () => {
const bytes1 = new Uint8Array([]);
const bytes2 = new Uint8Array([]);
assert.strictEqual(isEqualBytes(bytes1, bytes2), true);
});
it("should return false for byte arrays with same length but different content", () => {
const bytes1 = new Uint8Array([1, 2, 3, 4]);
const bytes2 = new Uint8Array([4, 3, 2, 1]);
assert.strictEqual(isEqualBytes(bytes1, bytes2), false);
});
});

View file

@ -1,13 +0,0 @@
export function isEqualBytes(bytes1: Uint8Array, bytes2: Uint8Array): boolean {
if (bytes1.length !== bytes2.length) {
return false;
}
for (let i = 0; i < bytes1.length; i++) {
if (bytes1[i] !== bytes2[i]) {
return false;
}
}
return true;
}

View file

@ -2,41 +2,72 @@ import { describe, it } from "node:test";
import assert from "node:assert";
import { isFileTypeMergable } from "./is-file-type-mergable";
const mergableExtensions = ["md", "txt"];
describe("isFileTypeMergable", () => {
it("should return true for .md files", () => {
assert.strictEqual(isFileTypeMergable(".md"), true);
assert.strictEqual(isFileTypeMergable("hi.md"), true);
assert.strictEqual(isFileTypeMergable(".md", mergableExtensions), true);
assert.strictEqual(
isFileTypeMergable("my/path/to/my/document.md"),
isFileTypeMergable("hi.md", mergableExtensions),
true
);
assert.strictEqual(
isFileTypeMergable("my/path/to/my/document.md", mergableExtensions),
true
);
});
it("should return true for .txt files", () => {
assert.strictEqual(isFileTypeMergable(".txt"), true);
assert.strictEqual(isFileTypeMergable("hi.txt"), true);
assert.strictEqual(
isFileTypeMergable("my/path/to/my/document.txt"),
isFileTypeMergable(".txt", mergableExtensions),
true
);
assert.strictEqual(
isFileTypeMergable("hi.txt", mergableExtensions),
true
);
assert.strictEqual(
isFileTypeMergable(
"my/path/to/my/document.txt",
mergableExtensions
),
true
);
});
it("should be case insensitive", () => {
assert.strictEqual(isFileTypeMergable("hi.MD"), true);
assert.strictEqual(
isFileTypeMergable("my/path/to/my/DOCUMENT.MD"),
isFileTypeMergable("hi.MD", mergableExtensions),
true
);
assert.strictEqual(isFileTypeMergable("hi.TXT"), true);
assert.strictEqual(
isFileTypeMergable("my/path/to/my/DOCUMENT.TXT"),
isFileTypeMergable("my/path/to/my/DOCUMENT.MD", mergableExtensions),
true
);
assert.strictEqual(
isFileTypeMergable("hi.TXT", mergableExtensions),
true
);
assert.strictEqual(
isFileTypeMergable(
"my/path/to/my/DOCUMENT.TXT",
mergableExtensions
),
true
);
});
it("should return false for non-mergable file types", () => {
assert.strictEqual(isFileTypeMergable(".json"), false);
assert.strictEqual(isFileTypeMergable("HELLO.JSON"), false);
assert.strictEqual(isFileTypeMergable("my/config.yml"), false);
assert.strictEqual(
isFileTypeMergable(".json", mergableExtensions),
false
);
assert.strictEqual(
isFileTypeMergable("HELLO.JSON", mergableExtensions),
false
);
assert.strictEqual(
isFileTypeMergable("my/config.yml", mergableExtensions),
false
);
});
});

View file

@ -1,6 +1,9 @@
export function isFileTypeMergable(pathOrFileName: string): boolean {
export function isFileTypeMergable(
pathOrFileName: string,
mergeableExtensions: string[]
): boolean {
const parts = pathOrFileName.split(".");
const fileExtension = parts.at(-1) ?? "";
return ["md", "txt"].includes(fileExtension.toLowerCase());
return mergeableExtensions.includes(fileExtension.toLowerCase());
}

View file

@ -13,7 +13,7 @@ export function lineAndColumnToPosition(
line: number,
column: number
): number {
const lines = text.replace("\r", "").split("\n");
const lines = text.replaceAll("\r", "").split("\n");
if (line >= lines.length) {
throw new Error(`Line number ${line} is out of range.`);

View file

@ -43,6 +43,28 @@ describe("positionToLineAndColumn", () => {
});
});
test("with multiple carriage returns", () => {
// Test that all \r characters are removed, not just the first one
const text = "line1\r\nline2\r\nline3\r\n";
assert.deepStrictEqual(positionToLineAndColumn(text, 0), {
line: 0,
column: 0
});
// Position 6 = start of 'line2' after all \r removed
assert.deepStrictEqual(positionToLineAndColumn(text, 6), {
line: 1,
column: 0
});
// Position 12 = start of 'line3' after all \r removed
assert.deepStrictEqual(positionToLineAndColumn(text, 12), {
line: 2,
column: 0
});
});
test("handles empty input", () => {
assert.deepStrictEqual(positionToLineAndColumn("", 0), {
line: 0,

View file

@ -14,13 +14,10 @@ export function positionToLineAndColumn(
throw new Error("Position cannot be negative");
}
text = text.replace("\r", "");
text = text.replaceAll("\r", "");
if (
position >
text.length + 1
// +1 to account for the cursor being after last character
) {
if (position > text.length) {
// position == text.length accounts for the cursor being after last character
throw new Error(
`Position ${position} exceeds text length ${text.length}`
);

View file

@ -10,7 +10,8 @@ import { sleep } from "./sleep";
*
* @template T - Type of the function to be rate limited
* @param {T} fn - The asynchronous function to rate limit
* @param {number} minIntervalMs - The minimum interval in milliseconds between function calls
* @param {number | (() => number)} minIntervalMs - Minimum interval in milliseconds between calls,
* or a function that returns the minimum interval
* @returns {(...args: Parameters<T>) => ReturnType<T> | Promise<undefined>} A decorated function that respects the rate limit.
* Returns the original function's return type when executed, or undefined if the call was superseded by a newer one.
*/
@ -21,7 +22,7 @@ export function rateLimit<
) => Promise<R>
>(
fn: T,
minIntervalMs: number
minIntervalMs: number | (() => number)
): (...args: Parameters<T>) => Promise<R | undefined> {
let newArgs: Parameters<T> | undefined = undefined;
let running: Promise<unknown> | undefined = undefined;
@ -46,7 +47,11 @@ export function rateLimit<
const [promise, resolve] = createPromise();
running = promise;
sleep(minIntervalMs)
sleep(
typeof minIntervalMs === "function"
? minIntervalMs()
: minIntervalMs
)
.then(resolve)
.catch(() => {
// sleep cannot fail

View file

@ -8,7 +8,9 @@
"scripts": {
"dev": "webpack watch --mode development",
"build": "webpack --mode production",
"test": "tsx --test src/**/*.test.ts"
"test": "tsx --test src/**/*.test.ts",
"test:deterministic": "npm run build && node dist/deterministic/cli.js",
"test:fuzzing": "npm run build && node dist/cli.js"
},
"devDependencies": {
"@types/node": "^24.8.1",

View file

@ -18,6 +18,7 @@ export class MockAgent extends MockClient {
initialSettings: Partial<SyncSettings>,
public readonly name: string,
private readonly doDeletes: boolean,
private readonly doResets: boolean,
useSlowFileEvents: boolean,
private readonly jitterScaleInSeconds: number
) {
@ -107,28 +108,41 @@ export class MockAgent extends MockClient {
}
}
this.pendingActions.push(
(async (): Promise<unknown> => {
try {
return await choose(options)();
} catch (error) {
this.client.logger.error(
`Failed to perform an action: ${error}`
);
this.client.logger.info(JSON.stringify(this.data, null, 2));
this.client.logger.info(
JSON.stringify(this.localFiles, null, 2)
);
throw error;
}
})()
);
if (Math.random() < 0.015 && this.doResets) {
// we can't just queue this up as once it's destroyed, no more method calls can go to SyncClient
await this.resetClient();
} else {
this.pendingActions.push(
(async (): Promise<unknown> => {
try {
return await choose(options)();
} catch (error) {
this.client.logger.error(
`Failed to perform an action: ${error}`
);
this.client.logger.info(
JSON.stringify(this.data, null, 2)
);
this.client.logger.info(
JSON.stringify(this.localFiles, null, 2)
);
throw error;
}
})()
);
}
}
public async finish(): Promise<void> {
await this.client.setSetting("isSyncEnabled", true);
// eslint-disable-next-line no-restricted-properties
await Promise.all(this.pendingActions);
await this.client.waitAndStop();
await this.client.waitUntilFinished();
}
public async destroy(): Promise<void> {
await this.client.waitUntilFinished();
await this.client.destroy();
}
public assertFileSystemsAreConsistent(otherAgent: MockAgent): void {
@ -228,6 +242,12 @@ export class MockAgent extends MockClient {
}
}
private async resetClient(): Promise<void> {
this.client.logger.info(`Resetting client ${this.name}`);
await this.client.destroy();
await this.init();
}
private async createFileAction(): Promise<void> {
const file = this.getFileName();

View file

@ -1,4 +1,4 @@
import type { StoredDatabase } from "sync-client";
import type { StoredDatabase, TextWithCursors } from "sync-client";
import { assert } from "../utils/assert";
import {
type RelativePath,
@ -6,7 +6,6 @@ import {
type SyncSettings,
SyncClient
} from "sync-client";
import type { TextWithCursors } from "reconcile-text";
export class MockClient implements FileSystemOperations {
protected readonly localFiles = new Map<string, Uint8Array>();

View file

@ -14,6 +14,7 @@ async function runTest({
concurrency,
iterations,
doDeletes,
doResets,
useSlowFileEvents,
jitterScaleInSeconds
}: {
@ -21,12 +22,13 @@ async function runTest({
concurrency: number;
iterations: number;
doDeletes: boolean;
doResets: boolean;
useSlowFileEvents: boolean;
jitterScaleInSeconds: number;
}): Promise<void> {
slowFileEvents = useSlowFileEvents;
const settings = `with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}, jitterScaleInSeconds ${jitterScaleInSeconds}, useSlowFileEvents ${useSlowFileEvents}`;
const settings = `with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}, doResets ${doResets}, jitterScaleInSeconds ${jitterScaleInSeconds}, useSlowFileEvents ${useSlowFileEvents}`;
console.info(`Running test ${settings}`);
const vaultName = uuidv4();
@ -46,6 +48,7 @@ async function runTest({
initialSettings,
`agent-${i}`,
doDeletes,
doResets,
useSlowFileEvents,
jitterScaleInSeconds
)
@ -53,10 +56,12 @@ async function runTest({
}
try {
// eslint-disable-next-line no-restricted-properties
await Promise.all(clients.map(async (client) => client.init()));
for (let i = 0; i < iterations; i++) {
console.info(`Iteration ${i + 1}/${iterations}`);
// eslint-disable-next-line no-restricted-properties
await Promise.all(clients.map(async (client) => client.act()));
await sleep(100);
}
@ -77,7 +82,7 @@ async function runTest({
// then we need a second pass to ensure that all agents pull the same state.
for (const client of clients) {
try {
await client.finish();
await client.destroy();
} catch (err) {
if (!slowFileEvents) {
throw err;
@ -112,6 +117,16 @@ async function runTest({
async function runTests(): Promise<void> {
for (let i = 0; i < TEST_ITERATIONS; i++) {
await runTest({
agentCount: 2,
concurrency: 16,
iterations: 100,
doDeletes: true,
doResets: true,
useSlowFileEvents: true,
jitterScaleInSeconds: 0.75
});
for (const useSlowFileEvents of [false, true]) {
for (const concurrency of [
16,
@ -123,6 +138,7 @@ async function runTests(): Promise<void> {
concurrency,
iterations: 100,
doDeletes,
doResets: false,
useSlowFileEvents,
jitterScaleInSeconds: 0.75
});

View file

@ -0,0 +1,68 @@
import { v4 as uuidv4 } from "uuid";
import { DeterministicTestRunner } from "./test-runner";
import { exampleTests } from "./example-tests";
const REMOTE_URI = "http://localhost:3000";
const TOKEN = "test-token-change-me";
async function runDeterministicTests(): Promise<void> {
console.info("=".repeat(80));
console.info("DETERMINISTIC E2E TESTS");
console.info("=".repeat(80));
console.info("");
let passed = 0;
let failed = 0;
for (const testDef of exampleTests) {
// Use a unique vault for each test to avoid interference
const vaultName = uuidv4();
const runner = new DeterministicTestRunner(
vaultName,
REMOTE_URI,
TOKEN
);
try {
await runner.runTest(testDef);
passed++;
} catch (error) {
failed++;
console.error(`Test "${testDef.name}" failed with error:`, error);
}
}
console.info("\n" + "=".repeat(80));
console.info("TEST SUMMARY");
console.info("=".repeat(80));
console.info(`Total tests: ${exampleTests.length}`);
console.info(`Passed: ${passed}`);
console.info(`Failed: ${failed}`);
console.info("=".repeat(80));
if (failed > 0) {
process.exit(1);
}
}
// Error handlers
process.on("uncaughtException", (error) => {
console.error("Uncaught exception:", error);
process.exit(1);
});
process.on("unhandledRejection", (error) => {
console.error("Unhandled rejection:", error);
process.exit(1);
});
// Run tests
runDeterministicTests()
.then(() => {
console.info("\n✓ All deterministic tests passed!");
process.exit(0);
})
.catch((error: unknown) => {
console.error("\n✗ Deterministic tests failed:", error);
process.exit(1);
});

View file

@ -0,0 +1,241 @@
import type { RelativePath, SyncSettings } from "sync-client";
import { MockClient } from "../agent/mock-client";
import { assert } from "../utils/assert";
export class DeterministicClient extends MockClient {
private pendingOperations: (() => Promise<void>)[] = [];
public constructor(
public readonly clientId: string,
initialSettings: Partial<SyncSettings>
) {
super(initialSettings, false);
}
/**
* Get the underlying SyncClient
*/
public getSyncClient() {
return this.client;
}
/**
* Create a file with specific content
*/
public async createFile(
path: RelativePath,
content: string,
immediate = true
): Promise<void> {
const operation = async (): Promise<void> => {
await this.create(path, new TextEncoder().encode(content));
};
if (immediate) {
await operation();
} else {
this.pendingOperations.push(operation);
}
}
/**
* Update a file with new content (replaces all content)
*/
public async updateFile(
path: RelativePath,
content: string,
immediate = true
): Promise<void> {
const operation = async (): Promise<void> => {
await this.write(path, new TextEncoder().encode(content));
};
if (immediate) {
await operation();
} else {
this.pendingOperations.push(operation);
}
}
/**
* Append content to a file
*/
public async appendToFile(
path: RelativePath,
content: string,
immediate = true
): Promise<void> {
const operation = async (): Promise<void> => {
await this.atomicUpdateText(path, (current) => ({
text: current.text + content,
cursors: []
}));
};
if (immediate) {
await operation();
} else {
this.pendingOperations.push(operation);
}
}
/**
* Delete a file
*/
public async deleteFile(
path: RelativePath,
immediate = true
): Promise<void> {
const operation = async (): Promise<void> => {
await this.delete(path);
};
if (immediate) {
await operation();
} else {
this.pendingOperations.push(operation);
}
}
/**
* Rename a file
*/
public async renameFile(
oldPath: RelativePath,
newPath: RelativePath,
immediate = true
): Promise<void> {
const operation = async (): Promise<void> => {
await this.rename(oldPath, newPath);
};
if (immediate) {
await operation();
} else {
this.pendingOperations.push(operation);
}
}
/**
* Flush all pending operations
*/
public async flush(): Promise<void> {
const operations = [...this.pendingOperations];
this.pendingOperations = [];
for (const operation of operations) {
await operation();
}
}
/**
* Wait until all sync operations are complete
*/
public async waitForSync(): Promise<void> {
await this.client.waitUntilFinished();
}
/**
* Enable or disable sync
*/
public async setSyncEnabled(enabled: boolean): Promise<void> {
await this.client.setSetting("isSyncEnabled", enabled);
}
/**
* Get file content as string
*/
public async getFileContent(path: RelativePath): Promise<string> {
const content = await this.read(path);
return new TextDecoder().decode(content);
}
/**
* Get number of files
*/
public async getFileCount(): Promise<number> {
const files = await this.listFilesRecursively();
return files.length;
}
/**
* Assert file exists or doesn't exist
*/
public async assertFileExists(
path: RelativePath,
shouldExist: boolean
): Promise<void> {
const exists = await this.exists(path);
assert(
exists === shouldExist,
`[${this.clientId}] Expected file ${path} to ${shouldExist ? "exist" : "not exist"}, but it ${exists ? "exists" : "doesn't exist"}`
);
}
/**
* Assert file content matches expected
*/
public async assertFileContent(
path: RelativePath,
expectedContent: string
): Promise<void> {
const content = await this.getFileContent(path);
assert(
content === expectedContent,
`[${this.clientId}] Expected file ${path} to have content "${expectedContent}", but it has "${content}"`
);
}
/**
* Assert file count matches expected
*/
public async assertFileCount(expectedCount: number): Promise<void> {
const count = await this.getFileCount();
assert(
count === expectedCount,
`[${this.clientId}] Expected ${expectedCount} files, but found ${count}`
);
}
/**
* Check if this client's filesystem is consistent with another client
*/
public async assertConsistentWith(
otherClient: DeterministicClient
): Promise<void> {
const thisFiles = await this.listFilesRecursively();
const otherFiles = await otherClient.listFilesRecursively();
const thisFilesSet = new Set(thisFiles);
const otherFilesSet = new Set(otherFiles);
const missingInOther = thisFiles.filter((f) => !otherFilesSet.has(f));
const missingInThis = otherFiles.filter((f) => !thisFilesSet.has(f));
assert(
missingInOther.length === 0,
`[${this.clientId}] Files missing in ${otherClient.clientId}: ${missingInOther.join(", ")}`
);
assert(
missingInThis.length === 0,
`[${this.clientId}] Files missing in this client from ${otherClient.clientId}: ${missingInThis.join(", ")}`
);
// Check content of all files
for (const file of thisFiles) {
const thisContent = await this.getFileContent(file);
const otherContent = await otherClient.getFileContent(file);
assert(
thisContent === otherContent,
`[${this.clientId}] Content mismatch for ${file}:\n This: "${thisContent}"\n Other: "${otherContent}"`
);
}
}
/**
* Cleanup
*/
public async destroy(): Promise<void> {
await this.client.destroy();
}
}

View file

@ -0,0 +1,143 @@
import type { RelativePath } from "sync-client";
/**
* Base event interface
*/
export interface BaseEvent {
type: string;
description?: string;
}
/**
* File operation events
*/
export interface CreateFileEvent extends BaseEvent {
type: "create-file";
clientId: string;
path: RelativePath;
content: string;
immediate?: boolean; // If true, sync immediately; if false, defer until flush
}
export interface UpdateFileEvent extends BaseEvent {
type: "update-file";
clientId: string;
path: RelativePath;
content: string;
immediate?: boolean;
}
export interface DeleteFileEvent extends BaseEvent {
type: "delete-file";
clientId: string;
path: RelativePath;
immediate?: boolean;
}
export interface RenameFileEvent extends BaseEvent {
type: "rename-file";
clientId: string;
oldPath: RelativePath;
newPath: RelativePath;
immediate?: boolean;
}
export interface AppendToFileEvent extends BaseEvent {
type: "append-to-file";
clientId: string;
path: RelativePath;
content: string;
immediate?: boolean;
}
/**
* Sync control events
*/
export interface FlushEvent extends BaseEvent {
type: "flush";
clientId: string;
}
export interface WaitForSyncEvent extends BaseEvent {
type: "wait-for-sync";
clientId?: string; // If undefined, wait for all clients
}
export interface EnableSyncEvent extends BaseEvent {
type: "enable-sync";
clientId: string;
}
export interface DisableSyncEvent extends BaseEvent {
type: "disable-sync";
clientId: string;
}
/**
* Timing events
*/
export interface SleepEvent extends BaseEvent {
type: "sleep";
milliseconds: number;
}
/**
* Assertion events
*/
export interface AssertFileExistsEvent extends BaseEvent {
type: "assert-file-exists";
clientId: string;
path: RelativePath;
shouldExist: boolean;
}
export interface AssertFileContentEvent extends BaseEvent {
type: "assert-file-content";
clientId: string;
path: RelativePath;
expectedContent: string;
}
export interface AssertFileCountEvent extends BaseEvent {
type: "assert-file-count";
clientId: string;
expectedCount: number;
}
export interface AssertAllClientsConsistentEvent extends BaseEvent {
type: "assert-all-clients-consistent";
}
export interface AssertClientsConsistentEvent extends BaseEvent {
type: "assert-clients-consistent";
clientIds: string[];
}
/**
* Union type of all events
*/
export type TestEvent =
| CreateFileEvent
| UpdateFileEvent
| DeleteFileEvent
| RenameFileEvent
| AppendToFileEvent
| FlushEvent
| WaitForSyncEvent
| EnableSyncEvent
| DisableSyncEvent
| SleepEvent
| AssertFileExistsEvent
| AssertFileContentEvent
| AssertFileCountEvent
| AssertAllClientsConsistentEvent
| AssertClientsConsistentEvent;
/**
* Test definition
*/
export interface TestDefinition {
name: string;
clients: string[]; // Client IDs
events: TestEvent[];
}

View file

@ -0,0 +1,350 @@
import type { TestDefinition } from "./events";
/**
* Simple test: Create a file on one client and verify it syncs to another
*/
export const simpleSync: TestDefinition = {
name: "Simple sync between two clients",
clients: ["client1", "client2"],
events: [
{
type: "create-file",
clientId: "client1",
path: "test.md",
content: "Hello, world!",
description: "Client 1 creates a file"
},
{
type: "wait-for-sync",
description: "Wait for all clients to sync"
},
{
type: "assert-file-exists",
clientId: "client2",
path: "test.md",
shouldExist: true,
description: "Verify file exists on Client 2"
},
{
type: "assert-file-content",
clientId: "client2",
path: "test.md",
expectedContent: "Hello, world!",
description: "Verify content matches on Client 2"
},
{
type: "assert-all-clients-consistent",
description: "Verify all clients are consistent"
}
]
};
/**
* Test concurrent edits to the same file
*/
export const concurrentEdits: TestDefinition = {
name: "Concurrent edits with operational transformation",
clients: ["client1", "client2"],
events: [
{
type: "create-file",
clientId: "client1",
path: "collaborative.md",
content: "Initial content ",
description: "Client 1 creates initial file"
},
{
type: "wait-for-sync",
description: "Wait for sync"
},
{
type: "disable-sync",
clientId: "client1",
description: "Disable sync on Client 1"
},
{
type: "disable-sync",
clientId: "client2",
description: "Disable sync on Client 2"
},
{
type: "append-to-file",
clientId: "client1",
path: "collaborative.md",
content: "EditA ",
description: "Client 1 appends offline"
},
{
type: "append-to-file",
clientId: "client2",
path: "collaborative.md",
content: "EditB ",
description: "Client 2 appends offline"
},
{
type: "enable-sync",
clientId: "client1",
description: "Re-enable sync on Client 1"
},
{
type: "enable-sync",
clientId: "client2",
description: "Re-enable sync on Client 2"
},
{
type: "wait-for-sync",
description: "Wait for conflict resolution"
},
{
type: "assert-all-clients-consistent",
description: "Verify both clients converged to same state"
}
]
};
/**
* Test file deletion propagation
*/
export const fileDeletion: TestDefinition = {
name: "File deletion syncs correctly",
clients: ["client1", "client2"],
events: [
{
type: "create-file",
clientId: "client1",
path: "to-delete.md",
content: "This file will be deleted",
description: "Client 1 creates a file"
},
{
type: "wait-for-sync",
description: "Wait for sync"
},
{
type: "assert-file-exists",
clientId: "client2",
path: "to-delete.md",
shouldExist: true,
description: "Verify file exists on Client 2"
},
{
type: "delete-file",
clientId: "client1",
path: "to-delete.md",
description: "Client 1 deletes the file"
},
{
type: "wait-for-sync",
description: "Wait for deletion to sync"
},
{
type: "assert-file-exists",
clientId: "client2",
path: "to-delete.md",
shouldExist: false,
description: "Verify file deleted on Client 2"
},
{
type: "assert-all-clients-consistent",
description: "Verify all clients are consistent"
}
]
};
/**
* Test file rename propagation
*/
export const fileRename: TestDefinition = {
name: "File rename syncs correctly",
clients: ["client1", "client2"],
events: [
{
type: "create-file",
clientId: "client1",
path: "old-name.md",
content: "Content that should persist",
description: "Client 1 creates a file"
},
{
type: "wait-for-sync",
description: "Wait for sync"
},
{
type: "assert-file-exists",
clientId: "client2",
path: "old-name.md",
shouldExist: true,
description: "Verify file exists on Client 2"
},
{
type: "rename-file",
clientId: "client1",
oldPath: "old-name.md",
newPath: "new-name.md",
description: "Client 1 renames the file"
},
{
type: "wait-for-sync",
description: "Wait for rename to sync"
},
{
type: "assert-file-exists",
clientId: "client2",
path: "old-name.md",
shouldExist: false,
description: "Verify old name doesn't exist on Client 2"
},
{
type: "assert-file-exists",
clientId: "client2",
path: "new-name.md",
shouldExist: true,
description: "Verify new name exists on Client 2"
},
{
type: "assert-file-content",
clientId: "client2",
path: "new-name.md",
expectedContent: "Content that should persist",
description: "Verify content preserved"
},
{
type: "assert-all-clients-consistent",
description: "Verify all clients are consistent"
}
]
};
/**
* Test deferred operations (batching)
*/
export const deferredOperations: TestDefinition = {
name: "Deferred operations batch correctly",
clients: ["client1", "client2"],
events: [
{
type: "create-file",
clientId: "client1",
path: "file1.md",
content: "File 1",
immediate: false,
description: "Queue creation of file 1 (not synced yet)"
},
{
type: "create-file",
clientId: "client1",
path: "file2.md",
content: "File 2",
immediate: false,
description: "Queue creation of file 2 (not synced yet)"
},
{
type: "create-file",
clientId: "client1",
path: "file3.md",
content: "File 3",
immediate: false,
description: "Queue creation of file 3 (not synced yet)"
},
{
type: "sleep",
milliseconds: 100,
description: "Wait a bit (files shouldn't sync yet)"
},
{
type: "assert-file-count",
clientId: "client2",
expectedCount: 0,
description: "Verify Client 2 has no files yet"
},
{
type: "flush",
clientId: "client1",
description: "Flush pending operations on Client 1"
},
{
type: "wait-for-sync",
description: "Wait for all files to sync"
},
{
type: "assert-file-count",
clientId: "client2",
expectedCount: 3,
description: "Verify Client 2 now has all 3 files"
},
{
type: "assert-all-clients-consistent",
description: "Verify all clients are consistent"
}
]
};
/**
* Test offline editing and conflict resolution
*/
export const offlineEditing: TestDefinition = {
name: "Offline editing and reconnection",
clients: ["client1", "client2"],
events: [
{
type: "create-file",
clientId: "client1",
path: "shared.md",
content: "Initial",
description: "Client 1 creates initial file"
},
{
type: "wait-for-sync",
description: "Wait for sync"
},
{
type: "disable-sync",
clientId: "client2",
description: "Client 2 goes offline"
},
{
type: "update-file",
clientId: "client1",
path: "shared.md",
content: "Updated by client 1",
description: "Client 1 updates while Client 2 offline"
},
{
type: "wait-for-sync",
clientId: "client1",
description: "Client 1 syncs"
},
{
type: "update-file",
clientId: "client2",
path: "shared.md",
content: "Updated by client 2 offline",
description: "Client 2 updates while offline"
},
{
type: "enable-sync",
clientId: "client2",
description: "Client 2 comes back online"
},
{
type: "wait-for-sync",
description: "Wait for sync and conflict resolution"
},
{
type: "assert-all-clients-consistent",
description: "Verify clients converged after reconnection"
}
]
};
/**
* All example tests
*/
export const exampleTests: TestDefinition[] = [
simpleSync,
concurrentEdits,
fileDeletion,
fileRename,
deferredOperations,
offlineEditing
];

View file

@ -0,0 +1,4 @@
export type * from "./events";
export * from "./test-runner";
export * from "./deterministic-client";
export * from "./example-tests";

View file

@ -0,0 +1,263 @@
import type { SyncSettings } from "sync-client";
import { debugging, Logger } from "sync-client";
import type { TestDefinition, TestEvent } from "./events";
import { DeterministicClient } from "./deterministic-client";
import { sleep } from "../utils/sleep";
import { assert } from "../utils/assert";
export class DeterministicTestRunner {
private readonly clients = new Map<string, DeterministicClient>();
private readonly jitterScaleInSeconds = 0.1; // Small jitter for realism
public constructor(
private readonly vaultName: string,
private readonly remoteUri: string,
private readonly token: string
) {}
/**
* Run a test definition
*/
public async runTest(testDef: TestDefinition): Promise<void> {
console.info(`\n${"=".repeat(60)}`);
console.info(`Running test: ${testDef.name}`);
console.info(`${"=".repeat(60)}\n`);
try {
// Initialize clients
await this.initializeClients(testDef.clients);
// Execute events in sequence
for (let i = 0; i < testDef.events.length; i++) {
const event = testDef.events[i];
await this.executeEvent(event, i);
}
console.info(`\n✓ Test passed: ${testDef.name}\n`);
} catch (error) {
console.error(`\n✗ Test failed: ${testDef.name}`);
console.error(`Error: ${error}\n`);
throw error;
} finally {
await this.cleanup();
}
}
/**
* Initialize all clients for the test
*/
private async initializeClients(clientIds: string[]): Promise<void> {
console.info(`Initializing ${clientIds.length} clients...`);
for (const clientId of clientIds) {
const settings: Partial<SyncSettings> = {
isSyncEnabled: true,
token: this.token,
vaultName: this.vaultName,
syncConcurrency: 16,
remoteUri: this.remoteUri
};
const client = new DeterministicClient(clientId, settings);
await client.init(
debugging.slowFetchFactory(this.jitterScaleInSeconds),
debugging.slowWebSocketFactory(
this.jitterScaleInSeconds,
new Logger()
)
);
// Verify connection
const connectionCheck = await client
.getSyncClient()
.checkConnection();
assert(
connectionCheck.isSuccessful,
`Failed to connect client ${clientId}`
);
this.clients.set(clientId, client);
console.info(` ✓ Initialized client: ${clientId}`);
}
console.info("");
}
/**
* Execute a single event
*/
private async executeEvent(event: TestEvent, index: number): Promise<void> {
const description = event.description ?? event.type;
console.info(`[${index}] ${description}`);
switch (event.type) {
case "create-file": {
const client = this.getClient(event.clientId);
await client.createFile(
event.path,
event.content,
event.immediate ?? true
);
break;
}
case "update-file": {
const client = this.getClient(event.clientId);
await client.updateFile(
event.path,
event.content,
event.immediate ?? true
);
break;
}
case "delete-file": {
const client = this.getClient(event.clientId);
await client.deleteFile(event.path, event.immediate ?? true);
break;
}
case "rename-file": {
const client = this.getClient(event.clientId);
await client.renameFile(
event.oldPath,
event.newPath,
event.immediate ?? true
);
break;
}
case "append-to-file": {
const client = this.getClient(event.clientId);
await client.appendToFile(
event.path,
event.content,
event.immediate ?? true
);
break;
}
case "flush": {
const client = this.getClient(event.clientId);
await client.flush();
break;
}
case "wait-for-sync": {
if (event.clientId) {
const client = this.getClient(event.clientId);
await client.waitForSync();
} else {
// Wait for all clients
await Promise.all(
Array.from(this.clients.values()).map(async (c) =>
c.waitForSync()
)
);
}
break;
}
case "enable-sync": {
const client = this.getClient(event.clientId);
await client.setSyncEnabled(true);
break;
}
case "disable-sync": {
const client = this.getClient(event.clientId);
await client.setSyncEnabled(false);
break;
}
case "sleep": {
await sleep(event.milliseconds);
break;
}
case "assert-file-exists": {
const client = this.getClient(event.clientId);
await client.assertFileExists(event.path, event.shouldExist);
console.info(
` ✓ Assertion passed: ${event.path} ${event.shouldExist ? "exists" : "doesn't exist"}`
);
break;
}
case "assert-file-content": {
const client = this.getClient(event.clientId);
await client.assertFileContent(
event.path,
event.expectedContent
);
console.info(
` ✓ Assertion passed: ${event.path} has expected content`
);
break;
}
case "assert-file-count": {
const client = this.getClient(event.clientId);
await client.assertFileCount(event.expectedCount);
console.info(
` ✓ Assertion passed: ${event.expectedCount} files`
);
break;
}
case "assert-all-clients-consistent": {
const clientList = Array.from(this.clients.values());
for (let i = 0; i < clientList.length - 1; i++) {
await clientList[i].assertConsistentWith(clientList[i + 1]);
}
console.info(` ✓ Assertion passed: all clients consistent`);
break;
}
case "assert-clients-consistent": {
const clientList = event.clientIds.map((id) =>
this.getClient(id)
);
for (let i = 0; i < clientList.length - 1; i++) {
await clientList[i].assertConsistentWith(clientList[i + 1]);
}
console.info(
` ✓ Assertion passed: clients ${event.clientIds.join(", ")} consistent`
);
break;
}
default: {
// @ts-expect-error - exhaustive check
throw new Error(`Unknown event type: ${event.type}`);
}
}
}
/**
* Get a client by ID
*/
private getClient(clientId: string): DeterministicClient {
const client = this.clients.get(clientId);
if (!client) {
throw new Error(`Client ${clientId} not found`);
}
return client;
}
/**
* Cleanup all clients
*/
private async cleanup(): Promise<void> {
console.info("Cleaning up clients...");
for (const [id, client] of this.clients) {
try {
await client.destroy();
console.info(` ✓ Destroyed client: ${id}`);
} catch (error) {
console.error(` ✗ Failed to destroy client ${id}: ${error}`);
}
}
this.clients.clear();
}
}

View file

@ -1,8 +1,7 @@
const path = require("path");
const webpack = require("webpack");
module.exports = {
entry: "./src/cli.ts",
const baseConfig = {
target: "node",
mode: "production",
optimization: {
@ -19,12 +18,28 @@ module.exports = {
resolve: {
extensions: [".ts", ".js"]
},
output: {
globalObject: "this",
filename: "cli.js",
path: path.resolve(__dirname, "dist")
},
plugins: [
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
]
};
module.exports = [
{
...baseConfig,
entry: "./src/cli.ts",
output: {
globalObject: "this",
filename: "cli.js",
path: path.resolve(__dirname, "dist")
}
},
{
...baseConfig,
entry: "./src/deterministic/cli.ts",
output: {
globalObject: "this",
filename: "deterministic/cli.js",
path: path.resolve(__dirname, "dist")
}
}
];

View file

@ -21,6 +21,7 @@ else
cargo fmt --all -- --check
fi
cargo install cargo-machete
cargo machete --with-metadata
echo "Running checks in frontend"
@ -41,6 +42,7 @@ cd ..
if [[ "$FIX_MODE" == true ]]; then
$0
else
echo "Success"
fi
echo "Success"

View file

@ -8,6 +8,9 @@ server:
max_body_size_mb: 512
max_clients_per_vault: 256
response_timeout_seconds: 60
mergeable_file_extensions:
- md
- txt
users:
user_configs:
- name: admin

View file

@ -2,6 +2,7 @@ use core::time::Duration;
use std::{collections::HashMap, sync::Arc};
use anyhow::{Context as _, Result};
use log::info;
use models::{
DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, VaultUpdateId,
};
@ -10,6 +11,7 @@ use sqlx::{sqlite::SqliteConnectOptions, types::chrono::Utc};
pub mod models;
use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions};
use tokio::sync::Mutex;
use tokio::time::Instant;
use uuid::fmt::Hyphenated;
use super::websocket::{
@ -18,11 +20,26 @@ use super::websocket::{
};
use crate::config::database_config::DatabaseConfig;
#[derive(Clone)]
struct PoolWithTimestamp {
pool: Pool<Sqlite>,
last_accessed: Instant,
}
impl std::fmt::Debug for PoolWithTimestamp {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PoolWithTimestamp")
.field("pool", &"Pool<Sqlite>")
.field("last_accessed", &self.last_accessed)
.finish()
}
}
#[derive(Clone, Debug)]
pub struct Database {
config: DatabaseConfig,
broadcasts: Broadcasts,
connection_pools: Arc<Mutex<HashMap<VaultId, Pool<Sqlite>>>>,
connection_pools: Arc<Mutex<HashMap<VaultId, PoolWithTimestamp>>>,
}
pub type Transaction<'a> = sqlx::Transaction<'a, Sqlite>;
@ -33,7 +50,7 @@ impl Database {
.await
.with_context(|| {
format!(
"Failed to create databases directory: {}",
"Failed to create databases directory at `{}`",
config.databases_directory_path.to_string_lossy()
)
})?;
@ -52,17 +69,26 @@ impl Database {
.trim_end_matches(".sqlite")
.to_owned();
let pool = Self::create_vault_database(config, &vault).await?;
connection_pools.insert(
vault.clone(),
Self::create_vault_database(config, &vault).await?,
PoolWithTimestamp {
pool,
last_accessed: Instant::now(),
},
);
}
Ok(Self {
let database = Self {
config: config.clone(),
connection_pools: Arc::new(Mutex::new(connection_pools)),
broadcasts: broadcasts.clone(),
})
};
// Start background task to cleanup idle connection pools
database.start_idle_pool_cleanup();
Ok(database)
}
async fn create_vault_database(
@ -84,7 +110,7 @@ impl Database {
.test_before_acquire(true)
.connect_with(connection_options)
.await
.with_context(|| format!("Cannot open database at {}", file_name.display()))?;
.with_context(|| format!("Cannot open database at `{}`", file_name.display()))?;
Self::run_migrations(&pool).await?;
@ -100,16 +126,26 @@ impl Database {
async fn get_connection_pool(&self, vault: &VaultId) -> Result<Pool<Sqlite>> {
let mut pools = self.connection_pools.lock().await;
if !pools.contains_key(vault) {
let pool = Self::create_vault_database(&self.config, vault).await?;
pools.insert(vault.clone(), pool);
pools.insert(
vault.clone(),
PoolWithTimestamp {
pool,
last_accessed: Instant::now(),
},
);
}
let pool = pools
.get(vault)
let pool_with_timestamp = pools
.get_mut(vault)
.expect("Pool was just inserted or already exists");
Ok(pool.clone())
// Update last accessed time
pool_with_timestamp.last_accessed = Instant::now();
Ok(pool_with_timestamp.pool.clone())
}
/// Attempting to write from this transaction might result in a
@ -218,7 +254,7 @@ impl Database {
.await
}
.with_context(|| {
format!("Cannot fetch latest documents since vault_update_id {vault_update_id}")
format!("Cannot fetch latest documents since vault_update_id `{vault_update_id}`")
})
.map(|rows| {
rows.into_iter()
@ -434,4 +470,42 @@ impl Database {
Ok(())
}
/// Cleanup idle connection pools that haven't been accessed in more than 5 minutes
async fn cleanup_idle_pools(&self) {
let mut pools = self.connection_pools.lock().await;
let now = Instant::now();
let idle_timeout = Duration::from_secs(5 * 60); // 5 minutes
// Collect vaults to remove
let vaults_to_remove: Vec<VaultId> = pools
.iter()
.filter(|(_, pool_with_timestamp)| {
now.duration_since(pool_with_timestamp.last_accessed) > idle_timeout
})
.map(|(vault_id, _)| vault_id.clone())
.collect();
// Close and remove idle pools
for vault_id in &vaults_to_remove {
if let Some(pool_with_timestamp) = pools.remove(vault_id) {
info!("Closing idle database connection pool for vault `{vault_id}`");
pool_with_timestamp.pool.close().await;
}
}
}
/// Start a background task that periodically cleans up idle connection pools
fn start_idle_pool_cleanup(&self) {
let database = self.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(60)); // Check every minute
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
database.cleanup_idle_pools().await;
}
});
}
}

View file

@ -1,6 +1,7 @@
use std::{collections::HashMap, sync::Arc};
use anyhow::Context;
use log::{debug, warn};
use tokio::sync::{Mutex, broadcast};
use super::models::WebSocketServerMessageWithOrigin;
@ -32,13 +33,18 @@ impl Broadcasts {
}
/// Notify all clients (who are subscribed to the vault) about an update.
/// We only log failures.
/// We only log failures and don't propagate them.
pub async fn send_document_update(
&self,
vault: VaultId,
document: WebSocketServerMessageWithOrigin,
) {
let tx = self.get_or_create(vault).await;
let tx = self.get_or_create(vault.clone()).await;
if tx.receiver_count() == 0 {
debug!("Skipping broadcast, no clients connected for vault `{vault}`");
return;
}
let result = tx
.send(document)
@ -46,7 +52,7 @@ impl Broadcasts {
.map_err(server_error);
if result.is_err() {
log::debug!("Failed to send message: {result:?}");
warn!("Failed to send message: {result:?}");
}
}

Some files were not shown because too many files have changed in this diff Show more