WIP: Smart create call #184
195 changed files with 18732 additions and 9439 deletions
4
.github/workflows/check.yml
vendored
4
.github/workflows/check.yml
vendored
|
|
@ -23,13 +23,13 @@ jobs:
|
||||||
- name: Setup Node.js environment
|
- name: Setup Node.js environment
|
||||||
uses: actions/setup-node@v4.2.0
|
uses: actions/setup-node@v4.2.0
|
||||||
with:
|
with:
|
||||||
node-version: "22.x"
|
node-version: "25.x"
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
- name: Setup Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: "1.89.0"
|
toolchain: "1.92.0"
|
||||||
components: clippy, rustfmt
|
components: clippy, rustfmt
|
||||||
|
|
||||||
- name: Lint & test
|
- name: Lint & test
|
||||||
|
|
|
||||||
13
.github/workflows/deploy-docs.yml
vendored
13
.github/workflows/deploy-docs.yml
vendored
|
|
@ -5,8 +5,8 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- "docs/**"
|
||||||
- '.github/workflows/deploy-docs.yml'
|
- ".github/workflows/deploy-docs.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|
@ -28,12 +28,11 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node.js environment
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4.2.0
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: "25.x"
|
||||||
cache: npm
|
check-latest: true
|
||||||
cache-dependency-path: docs/package-lock.json
|
|
||||||
|
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v4
|
uses: actions/configure-pages@v4
|
||||||
|
|
|
||||||
6
.github/workflows/e2e.yml
vendored
6
.github/workflows/e2e.yml
vendored
|
|
@ -6,7 +6,7 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 * * * *'
|
- cron: "0 * * * *"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|
@ -28,13 +28,13 @@ jobs:
|
||||||
- name: Setup Node.js environment
|
- name: Setup Node.js environment
|
||||||
uses: actions/setup-node@v4.2.0
|
uses: actions/setup-node@v4.2.0
|
||||||
with:
|
with:
|
||||||
node-version: "22.x"
|
node-version: "25.x"
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Setup Rust toolchain
|
- name: Setup Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: "1.89.0"
|
toolchain: "1.92.0"
|
||||||
components: clippy, rustfmt
|
components: clippy, rustfmt
|
||||||
|
|
||||||
- name: Setup rust
|
- name: Setup rust
|
||||||
|
|
|
||||||
4
.github/workflows/publish-plugin.yml
vendored
4
.github/workflows/publish-plugin.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
||||||
- name: Setup Node.js environment
|
- name: Setup Node.js environment
|
||||||
uses: actions/setup-node@v4.2.0
|
uses: actions/setup-node@v4.2.0
|
||||||
with:
|
with:
|
||||||
node-version: "22.x"
|
node-version: "25.x"
|
||||||
check-latest: true
|
check-latest: true
|
||||||
|
|
||||||
- name: Build plugin
|
- name: Build plugin
|
||||||
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
- name: Setup Rust toolchain
|
- name: Setup Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
with:
|
with:
|
||||||
toolchain: "1.89.0"
|
toolchain: "1.92.0"
|
||||||
components: clippy, rustfmt
|
components: clippy, rustfmt
|
||||||
|
|
||||||
- name: Install cross-compilation tools
|
- name: Install cross-compilation tools
|
||||||
|
|
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -7,15 +7,19 @@ node_modules
|
||||||
# Frontend build folders
|
# Frontend build folders
|
||||||
frontend/*/dist
|
frontend/*/dist
|
||||||
|
|
||||||
sync-server/db.sqlite3*
|
|
||||||
sync-server/databases
|
|
||||||
|
|
||||||
# Rust build folders
|
# Rust build folders
|
||||||
sync-server/target
|
sync-server/target
|
||||||
sync-server/artifacts
|
sync-server/artifacts
|
||||||
sync-server/bindings/*.ts
|
sync-server/bindings/*.ts
|
||||||
|
|
||||||
|
# build folders
|
||||||
|
sync-server/db.sqlite3*
|
||||||
|
sync-server/databases
|
||||||
|
frontend/deterministic-tests/databases
|
||||||
|
|
||||||
*.log
|
*.log
|
||||||
*.sqlx
|
*.sqlx
|
||||||
|
|
||||||
target
|
target
|
||||||
|
|
||||||
|
.task
|
||||||
|
|
|
||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
|
@ -5,6 +5,6 @@
|
||||||
"**/dist": true,
|
"**/dist": true,
|
||||||
"**/node_modules": true,
|
"**/node_modules": true,
|
||||||
"**/.sqlx": true,
|
"**/.sqlx": true,
|
||||||
"**/target": true,
|
"**/target": true
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
461
CLAUDE.md
461
CLAUDE.md
|
|
@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
VaultLink is a self-hosted Obsidian plugin for real-time collaborative file syncing. The project consists of a Rust-based sync server and a TypeScript frontend with three main components: an Obsidian plugin, a sync client library, and a test client.
|
VaultLink is a self-hosted Obsidian plugin for real-time collaborative file syncing. The project consists of a Rust-based sync server and a TypeScript frontend with four main components: an Obsidian plugin, a sync client library, a test client, and a standalone CLI client.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -13,21 +13,103 @@ VaultLink is a self-hosted Obsidian plugin for real-time collaborative file sync
|
||||||
- **sync-server/**: Rust-based WebSocket server with SQLite database for document versioning and real-time synchronization
|
- **sync-server/**: Rust-based WebSocket server with SQLite database for document versioning and real-time synchronization
|
||||||
- **frontend/sync-client/**: TypeScript library providing core sync functionality, WebSocket management, and file operations
|
- **frontend/sync-client/**: TypeScript library providing core sync functionality, WebSocket management, and file operations
|
||||||
- **frontend/obsidian-plugin/**: Obsidian plugin that integrates the sync client with Obsidian's API
|
- **frontend/obsidian-plugin/**: Obsidian plugin that integrates the sync client with Obsidian's API
|
||||||
- **frontend/test-client/**: CLI testing tool for the sync functionality
|
- **frontend/test-client/**: CLI testing tool for simulating multiple concurrent users
|
||||||
|
- **frontend/local-client-cli/**: Standalone CLI for VaultLink sync client
|
||||||
|
- **frontend/history-ui/**: Svelte 5 web UI for browsing vault history, viewing diffs, and restoring versions
|
||||||
|
|
||||||
### Key Technologies
|
### Key Technologies
|
||||||
|
|
||||||
- **Backend**: Rust with Axum framework, SQLite with SQLx, WebSockets for real-time sync
|
- **Backend**: Rust with Axum framework, SQLite with SQLx, WebSockets for real-time sync
|
||||||
- **Frontend**: TypeScript, Webpack for bundling, Jest for testing
|
- **Frontend**: TypeScript, Webpack for bundling, Node.js native test runner
|
||||||
|
- **History UI**: Svelte 5 with runes, Vite for bundling, embedded in server binary via `rust-embed`
|
||||||
- **Sync Algorithm**: Uses reconcile-text library for operational transformation
|
- **Sync Algorithm**: Uses reconcile-text library for operational transformation
|
||||||
|
|
||||||
|
### Architectural Patterns
|
||||||
|
|
||||||
|
**Server Architecture:**
|
||||||
|
|
||||||
|
- `AppState`: Central state container holding `Database`, `Cursors`, and `Broadcasts`
|
||||||
|
- `Database`: SQLite-backed document versioning with SQLx for compile-time query verification
|
||||||
|
- `Broadcasts`: WebSocket broadcast system for real-time updates to connected clients
|
||||||
|
- `Cursors`: Tracks user cursor positions across documents with background cleanup task
|
||||||
|
|
||||||
|
**Client Architecture:**
|
||||||
|
|
||||||
|
- `SyncClient`: Main entry point, orchestrates all sync operations
|
||||||
|
- `SyncService`: HTTP API client for CRUD operations on documents
|
||||||
|
- `WebSocketManager`: Manages WebSocket connection and real-time updates
|
||||||
|
- `Syncer`: Coordinates file synchronization between local filesystem and server
|
||||||
|
- `CursorTracker`: Manages local and remote cursor positions
|
||||||
|
- `Database`: Client-side document metadata cache
|
||||||
|
- `FileOperations`: Abstraction layer for filesystem operations
|
||||||
|
|
||||||
|
**Dual-Bundle Strategy:**
|
||||||
|
The sync-client builds two separate bundles:
|
||||||
|
|
||||||
|
- `sync-client.web.js`: Browser-compatible UMD bundle (excludes `ws` package)
|
||||||
|
- `sync-client.node.js`: Node.js CommonJS bundle with WebSocket support
|
||||||
|
|
||||||
|
**History UI Architecture:**
|
||||||
|
|
||||||
|
The history UI (`frontend/history-ui/`) is a standalone Svelte 5 SPA that provides read-only vault history browsing. It communicates with the server via the same REST API used by sync clients, plus three additional endpoints:
|
||||||
|
|
||||||
|
- `GET /vaults/:vault_id/documents/:document_id/versions` — all versions of a document (without content)
|
||||||
|
- `GET /vaults/:vault_id/history?limit=&before_update_id=` — paginated vault-wide version history (cursor-based)
|
||||||
|
- `POST /vaults/:vault_id/documents/:document_id/restore` — restore a document to a historical version (creates a new version with old content)
|
||||||
|
|
||||||
|
Server-side implementation:
|
||||||
|
- Database methods: `get_document_versions()` and `get_vault_history()` in `database.rs`, plus a `VaultHistoryRow` helper struct for `sqlx::query_as!`
|
||||||
|
- Handlers: `fetch_document_versions.rs`, `fetch_vault_history.rs`, `restore_document_version.rs`
|
||||||
|
- Response type: `VaultHistoryResponse { versions, hasMore }` in `responses.rs`
|
||||||
|
- SPA serving: `rust-embed` embeds `frontend/history-ui/dist/` into the binary; `index.rs` serves the SPA at `/` and assets at `/assets/*`
|
||||||
|
|
||||||
|
Client-side component hierarchy:
|
||||||
|
- `App.svelte` — session restore, routing
|
||||||
|
- `Login.svelte` — vault name + token auth via `/ping`
|
||||||
|
- `Dashboard.svelte` — main layout: file tree sidebar, activity feed, time-travel slider
|
||||||
|
- `DocumentDetail.svelte` — version timeline, content preview, diff view, restore
|
||||||
|
- `DiffView.svelte` — unified diff with LCS algorithm
|
||||||
|
- `FileTree.svelte` — recursive tree built from flat `relativePath` values
|
||||||
|
- `ActivityFeed.svelte` — git-log-style feed with action pills (created/updated/renamed/deleted/restored)
|
||||||
|
- `TimeSlider.svelte` — scrubs through `vaultUpdateId` range, reconstructs vault state at any point
|
||||||
|
|
||||||
|
State is managed with Svelte 5 runes (`$state`, `$derived`, `$effect`) in `lib/stores.svelte.ts`. Auth is stored in `sessionStorage`. The API client (`lib/api.ts`) sets `Authorization: Bearer` and `device-id: history-ui` headers on all requests.
|
||||||
|
|
||||||
## Development Commands
|
## Development Commands
|
||||||
|
|
||||||
|
### Initial Setup
|
||||||
|
|
||||||
|
**Node.js (requires version 25):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
|
||||||
|
nvm install 25
|
||||||
|
nvm use 25
|
||||||
|
nvm alias default 25 # Optional: set as system default
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rust:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
|
||||||
|
cargo install sqlx-cli cargo-machete cargo-edit cargo-insta
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
### Server Development
|
### Server Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd sync-server
|
cd sync-server
|
||||||
cargo run config-e2e.yml # Start development server
|
cargo run config-e2e.yml # Start development server
|
||||||
cargo test --verbose # Run Rust tests
|
cargo test --verbose # Run all Rust tests
|
||||||
|
cargo test <test_name> # Run specific test
|
||||||
cargo clippy --all-targets --all-features # Lint Rust code
|
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 clippy --all-targets --all-features --fix --allow-dirty --allow-staged # Auto-fix clippy warnings
|
||||||
cargo fmt --all -- --check # Check Rust formatting
|
cargo fmt --all -- --check # Check Rust formatting
|
||||||
|
|
@ -36,75 +118,374 @@ cargo machete --with-metadata # Detect unused dependencies
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Development
|
### Frontend Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm run dev # Start development mode (watches sync-client and obsidian-plugin)
|
npm run dev # Start development mode (watches sync-client and obsidian-plugin)
|
||||||
npm run build # Build all workspaces
|
npm run build # Build all workspaces
|
||||||
npm run test # Run all tests
|
npm run build -w sync-client # Build specific workspace
|
||||||
npm run lint # Lint and format TypeScript code
|
npm run test # Run all tests across all workspaces
|
||||||
|
npm run test -w sync-client # Run tests for specific workspace
|
||||||
|
npm run lint # Lint and format TypeScript code with ESLint + Prettier
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Setup (Development)
|
### History UI Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run dev -w history-ui # Start Vite dev server (localhost:5173, proxies API to localhost:3000)
|
||||||
|
npm run build -w history-ui # Build for production (output: frontend/history-ui/dist/)
|
||||||
|
```
|
||||||
|
|
||||||
|
The history UI is a Svelte 5 SPA embedded in the server binary via `rust-embed`. The build flow is:
|
||||||
|
|
||||||
|
1. `npm run build -w history-ui` produces `frontend/history-ui/dist/`
|
||||||
|
2. The Rust server embeds these files at compile time (`sync-server/src/server/index.rs`)
|
||||||
|
3. The server serves `index.html` at `GET /` and static assets at `GET /assets/*`
|
||||||
|
4. If the dist directory doesn't exist at Rust compile time, `build.rs` creates a placeholder
|
||||||
|
|
||||||
|
During development, run the Vite dev server separately and use its proxy to forward API calls to the running sync server.
|
||||||
|
|
||||||
|
### Database Operations
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd sync-server
|
cd sync-server
|
||||||
|
# Create/reset database for development
|
||||||
|
rm -rf db.sqlite*
|
||||||
sqlx database create --database-url sqlite://db.sqlite3
|
sqlx database create --database-url sqlite://db.sqlite3
|
||||||
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||||
cargo sqlx prepare --workspace
|
cargo sqlx prepare --workspace
|
||||||
|
|
||||||
|
# Add new migration
|
||||||
|
sqlx migrate add --source src/app_state/database/migrations <migration_name>
|
||||||
|
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||||
```
|
```
|
||||||
|
|
||||||
### Initial Setup
|
### Project Scripts
|
||||||
```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). **Run before pushing.**
|
||||||
- `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/check.sh --fix`: Same as above but auto-fixes linting and formatting issues
|
||||||
- `scripts/e2e.sh`: End-to-end testing
|
- `scripts/e2e.sh`: End-to-end testing (e.g., `scripts/e2e.sh 8` for 8 concurrent clients)
|
||||||
- `scripts/clean-up.sh`: Clean logs and database files
|
- `scripts/clean-up.sh`: Clean logs and database files
|
||||||
- `scripts/bump-version.sh patch`: Publish new version
|
- `scripts/bump-version.sh patch`: Publish new version (options: patch, minor, major)
|
||||||
- `scripts/update-api-types.sh`: Update TypeScript bindings from Rust types
|
- `scripts/update-api-types.sh`: Update TypeScript bindings from Rust types (uses ts-rs)
|
||||||
|
|
||||||
## Code Structure
|
## Code Structure
|
||||||
|
|
||||||
### Workspace Configuration
|
### Workspace Configuration
|
||||||
The frontend uses npm workspaces with four packages:
|
|
||||||
- `sync-client`: Core synchronization logic
|
The frontend uses npm workspaces with five packages:
|
||||||
|
|
||||||
|
- `sync-client`: Core synchronization logic (builds dual bundles for web and Node.js)
|
||||||
- `obsidian-plugin`: Obsidian-specific integration
|
- `obsidian-plugin`: Obsidian-specific integration
|
||||||
- `test-client`: Testing utilities
|
- `test-client`: Testing utilities for E2E tests
|
||||||
- `local-client-cli`: Standalone CLI for VaultLink sync client
|
- `local-client-cli`: Standalone CLI for VaultLink sync client
|
||||||
|
- `history-ui`: Svelte 5 SPA for vault history browsing (built with Vite, embedded in server binary)
|
||||||
|
|
||||||
### Type Generation
|
### Type Generation and API Updates
|
||||||
Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/bindings/` and used by frontend packages.
|
|
||||||
|
|
||||||
### Key Files
|
Rust structs generate TypeScript types via ts-rs crate:
|
||||||
- `sync-server/src/`: Rust server implementation with WebSocket handlers
|
|
||||||
- `frontend/sync-client/src/sync-client.ts`: Main sync client entry point
|
1. Rust structs annotated with `#[derive(TS)]` export to `sync-server/bindings/`
|
||||||
- `frontend/obsidian-plugin/src/vault-link-plugin.ts`: Main Obsidian plugin class
|
2. Run `scripts/update-api-types.sh` to copy bindings to `frontend/sync-client/src/services/types/`
|
||||||
- `frontend/sync-client/src/services/sync-service.ts`: Core synchronization logic
|
3. Frontend imports these types for type-safe API communication
|
||||||
|
|
||||||
|
### Important Implementation Details
|
||||||
|
|
||||||
|
**SQLx Compile-Time Verification:**
|
||||||
|
|
||||||
|
- SQLx verifies SQL queries at compile time against the database schema
|
||||||
|
- Run `cargo sqlx prepare --workspace` after schema changes to update `.sqlx/` directory
|
||||||
|
- CI builds require prepared query metadata to avoid needing a live database
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
- Server: `cargo test --verbose`
|
|
||||||
- Frontend: `npm run test` (runs Jest across all workspaces)
|
**Server:**
|
||||||
- E2E: `scripts/e2e.sh`
|
|
||||||
|
```bash
|
||||||
|
cargo test --verbose # All tests
|
||||||
|
cargo test <test_name> # Specific test
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test # All workspaces
|
||||||
|
npm run test -w sync-client # Specific workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
**E2E:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/e2e.sh 8 # 8 concurrent clients
|
||||||
|
scripts/clean-up.sh # Clean up after tests
|
||||||
|
```
|
||||||
|
|
||||||
### Test Structure
|
### Test Structure
|
||||||
- Rust: Unit tests alongside source files
|
|
||||||
- TypeScript: `.test.ts` files using Jest
|
|
||||||
- E2E: Uses test-client to simulate multiple concurrent users
|
|
||||||
|
|
||||||
## Code Style
|
- **Rust**: Unit tests alongside source files, uses `cargo-insta` for snapshot testing
|
||||||
|
- **TypeScript**: `.test.ts` files using Node.js native test runner (not Jest)
|
||||||
|
- **E2E**: Uses `test-client` to simulate multiple concurrent users with random operations
|
||||||
|
|
||||||
|
## Code Style and Formatting
|
||||||
|
|
||||||
### Rust
|
### Rust
|
||||||
- Uses extensive Clippy lints (see Cargo.toml)
|
|
||||||
- Follows pedantic linting rules
|
- Extensive Clippy lints (see `Cargo.toml`)
|
||||||
|
- Pedantic linting rules enabled
|
||||||
- Forbids unsafe code
|
- Forbids unsafe code
|
||||||
- Uses cargo fmt with default settings
|
- Uses `rustfmt.toml` for formatting configuration (4 spaces, Unix line endings)
|
||||||
|
- Run `cargo fmt --all` to format
|
||||||
|
|
||||||
### TypeScript
|
### TypeScript
|
||||||
- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings
|
|
||||||
- ESLint with unused imports plugin
|
- **Prettier**: 4-space indentation, no trailing commas, LF line endings
|
||||||
- Consistent across all three frontend packages
|
- **YAML/Markdown override**: 2-space indentation (via prettier config)
|
||||||
|
- **ESLint**: Strict rules with unused imports detection
|
||||||
|
- Configuration in `frontend/package.json`
|
||||||
|
- Run `npm run lint` to format and fix issues
|
||||||
|
|
||||||
|
### Svelte (History UI)
|
||||||
|
|
||||||
|
- Uses Svelte 5 runes syntax (`$state`, `$derived`, `$effect`, `$props`)
|
||||||
|
- Vite as bundler with `@sveltejs/vite-plugin-svelte`
|
||||||
|
- Excluded from the main ESLint config (Svelte files need different linting); `history-ui/**` is in the eslint ignores list
|
||||||
|
- CSS is component-scoped via Svelte's `<style>` blocks with CSS custom properties defined in `app.css`
|
||||||
|
|
||||||
|
### EditorConfig
|
||||||
|
|
||||||
|
- `.editorconfig` at project root defines baseline formatting rules
|
||||||
|
- `rustfmt.toml` and Prettier config explicitly mirror these settings
|
||||||
|
- Both formatters enforce: 4-space indent (2 for YAML/MD), LF endings, final newline, trim trailing whitespace
|
||||||
|
|
||||||
|
## Sync Logic Deep Dive
|
||||||
|
|
||||||
|
### Document Lifecycle
|
||||||
|
|
||||||
|
Documents go through these states on the client:
|
||||||
|
|
||||||
|
1. **Pending create**: `metadata === undefined`, `idempotencyKey` set. File exists locally but hasn't been confirmed by the server yet.
|
||||||
|
2. **Synced**: `metadata` has `documentId`, `parentVersionId`, `hash`. The server knows about this document.
|
||||||
|
3. **Deleted**: `isDeleted === true`. Locally deleted, may or may not be synced to server yet.
|
||||||
|
|
||||||
|
Pending creates are persisted to the local DB (via `StoredPendingDocument`) so they survive app crashes.
|
||||||
|
|
||||||
|
### Create Flow and Idempotency
|
||||||
|
|
||||||
|
The create flow is designed to handle interrupted creates (lost responses, app crashes):
|
||||||
|
|
||||||
|
1. Client generates `idempotencyKey` (UUID) and persists it locally before sending the request
|
||||||
|
2. Client sends HTTP POST with the key and file content to the server
|
||||||
|
3. Server checks if the `idempotency_key` already exists — if so, returns existing document (idempotent)
|
||||||
|
4. Server stores the key in the `documents` table alongside the document version
|
||||||
|
5. When a create results in a merge (document already exists at that path), both the original key and the new key are preserved — they're on different version rows of the same document
|
||||||
|
|
||||||
|
On reconnect, the client calls `POST /documents/resolve-keys` with all pending idempotency keys. The server maps each key to a `documentId`. The client assigns these documentIds to pending documents so they're recognized during subsequent remote fetch, preventing duplicates.
|
||||||
|
|
||||||
|
If key resolution fails (e.g., during a SyncReset), the pending creates retry normally with the same key — the server deduplicates.
|
||||||
|
|
||||||
|
### Server-Side Smart Create
|
||||||
|
|
||||||
|
When a client sends a create request for a path where a document already exists:
|
||||||
|
|
||||||
|
1. Server calls `merge_with_stored_version` instead of creating a new document
|
||||||
|
2. Content is 3-way merged using `reconcile-text` (for text files) or last-write-wins (for binary)
|
||||||
|
3. The response uses the EXISTING document's `documentId` — the client adopts it
|
||||||
|
4. The `idempotency_key` from the create request is stored on the new merged version
|
||||||
|
|
||||||
|
### Concurrency Model (Client)
|
||||||
|
|
||||||
|
The client uses two layers of concurrency control:
|
||||||
|
|
||||||
|
1. **PQueue (`syncQueue`)**: Limits concurrent sync operations (configurable via `syncConcurrency`)
|
||||||
|
2. **Locks (`updatedDocumentsByPathAndKeysLocks`)**: Per-document locks keyed by `relativePath` and `documentId`
|
||||||
|
|
||||||
|
**Critical ordering**: Locks are acquired INSIDE the queue, not outside. Acquiring locks while waiting for queue slots causes deadlocks (two operations hold locks on different keys while both waiting for queue capacity).
|
||||||
|
|
||||||
|
```
|
||||||
|
syncQueue.add(async () =>
|
||||||
|
locks.withLock(keys, operation) // lock acquired only when queue slot is available
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sync Reset and Recovery
|
||||||
|
|
||||||
|
A `SyncResetError` is thrown when the WebSocket disconnects or sync is toggled off. This:
|
||||||
|
- Clears the sync queue
|
||||||
|
- Rejects all pending lock waiters
|
||||||
|
- On reconnect, `scheduleSyncForOfflineChanges()` runs to reconcile local state with server
|
||||||
|
|
||||||
|
**Important**: `SyncResetError` during `syncRemotelyUpdatedFile` must be caught and logged as INFO, not ERROR. The test client exits on ERROR-level logs (except retries), so logging SyncResetError as ERROR during expected resets causes false test failures.
|
||||||
|
|
||||||
|
### The Offline Sync Algorithm (`scheduleSyncForOfflineChanges`)
|
||||||
|
|
||||||
|
Runs on reconnect to detect what changed while offline:
|
||||||
|
|
||||||
|
1. **Resolve idempotency keys first**: Call `resolveIdempotencyKeys()` to map pending creates to server-side documentIds before scanning files
|
||||||
|
2. List all local files
|
||||||
|
3. For each file with metadata: schedule as update (hash comparison will skip unchanged)
|
||||||
|
4. For each file without metadata: try to match against "deleted" DB records by content hash (detects moves). If no match, schedule as create.
|
||||||
|
5. For DB records whose files don't exist locally: schedule as delete
|
||||||
|
6. Ordering is: interrupted-deletes → updates → creates → possibly-deleted-deletes. Creates run BEFORE possibly-deleted deletes so that the server can merge creates with existing documents at the same path (preserving documentIds). If deletes ran first, a renamed+edited file would get a new documentId instead of adopting the existing one.
|
||||||
|
|
||||||
|
### Remote Update Processing
|
||||||
|
|
||||||
|
When the server broadcasts updates via WebSocket:
|
||||||
|
|
||||||
|
1. `scheduleSyncForOfflineChanges()` runs first (ensures local changes are queued)
|
||||||
|
2. For each remote document update:
|
||||||
|
- If client knows the `documentId`: treat as update to existing doc
|
||||||
|
- If client doesn't know the `documentId`: it's a new remote document — create locally
|
||||||
|
3. Before creating a new local file for an unknown remote doc, check if a pending local create exists at the same `originalCreationPath`. If so, skip (the pending retry with idempotency key will handle it).
|
||||||
|
|
||||||
|
### Known Concurrency Pitfalls
|
||||||
|
|
||||||
|
1. **Interrupted create + rename + modify**: A create request succeeds on the server but the response is lost. The file is renamed and modified locally. On reconnect, the idempotency key resolution maps the pending doc to the server's documentId, preventing a duplicate.
|
||||||
|
|
||||||
|
2. **Two clients create at same path**: Both send creates with different idempotency keys. Server merges them under one `documentId`. Each key is stored on its respective version row. Both clients can resolve their keys to the same document.
|
||||||
|
|
||||||
|
3. **Lock ordering**: Multi-key locks are sorted alphabetically to prevent deadlocks. Lock acquisition is sequential (not concurrent) even for multiple keys.
|
||||||
|
|
||||||
|
4. **`resolvedDocuments` vs `pendingDocuments`**: `resolvedDocuments` only includes docs with metadata (filters by `metadata !== undefined`). `pendingDocuments` returns docs with `metadata === undefined && !isDeleted`. Never confuse the two — scanning `resolvedDocuments` for pending docs returns nothing.
|
||||||
|
|
||||||
|
5. **`saveInTheBackground` triggers `ensureConsistency`**: The consistency check calls `resolvedDocuments` which can throw if there are duplicate paths with the same `parallelVersion`. Avoid calling `saveInTheBackground` during operations that temporarily create inconsistent state — use `save()` directly instead. This is why `createNewPendingDocument` calls `save()` directly.
|
||||||
|
|
||||||
|
6. **Pending doc `parallelVersion` on load**: When loading pending documents from storage, compute `parallelVersion` based on existing docs at the same path (use `getLatestDocumentByRelativePath` to find the current max). Setting all to 0 causes collisions if a resolved doc at the same path also has `parallelVersion: 0`.
|
||||||
|
|
||||||
|
7. **Key resolution with stale documentIds**: When `resolveIdempotencyKeys` returns a documentId, check `getDocumentByDocumentId` first. If another document already has that ID (assigned through normal sync), remove the stale pending doc instead of creating a duplicate.
|
||||||
|
|
||||||
|
8. **`resolveIdempotencyKeys` uses `retryForever`**: The HTTP call to `/documents/resolve-keys` retries forever like all other sync service calls. `SyncResetError` is re-thrown by `retryForever`, so the pipeline properly aborts on WebSocket disconnect without deadlocking.
|
||||||
|
|
||||||
|
### E2E Test Configuration
|
||||||
|
|
||||||
|
The test client (`frontend/test-client/src/cli.ts`) runs 5 iterations of 9 test configurations per process:
|
||||||
|
- 2 agents, concurrency 16 and 1, with/without deletes, with/without resets, with/without slow file events
|
||||||
|
- Tests assert: file system consistency between agents AND no duplicate content across files
|
||||||
|
- Uses `jitterScaleInSeconds: 0.75` to simulate network latency
|
||||||
|
|
||||||
|
**Running E2E**: Requires a server running with `config-e2e.yml`. Always clean the server databases before running. Use `scripts/e2e.sh 8` for 8 concurrent processes (each running the full test suite independently).
|
||||||
|
|
||||||
|
**E2E test harness known issue**: The named pipe mechanism for log collection can cause processes to hang when debug output exceeds the pipe buffer size. This is an infrastructure issue, not a sync bug. If processes appear stuck with logs that stopped growing, it's likely a pipe buffer issue.
|
||||||
|
|
||||||
|
### File Operations Abstraction
|
||||||
|
|
||||||
|
`FileOperations` has an `ensureClearPath` method that renames existing files to `(1).md`, `(2).md` etc. if a file already exists at the target path. This prevents data loss but can create apparent duplicates if the sync logic doesn't handle it.
|
||||||
|
|
||||||
|
The `write` method does a 3-way merge: `write(path, oldContent, newContent)`. It reads the current file, computes a diff from `oldContent` to `newContent`, and applies that diff to the current file content. This preserves local changes that happened between the read and write. If the old content doesn't match what's expected, the merge can fail with "Part X not found in new content".
|
||||||
|
|
||||||
|
### Approaches That Were Tried and Failed
|
||||||
|
|
||||||
|
When fixing the duplicate-document-after-interrupted-create problem, several heuristic approaches were attempted before landing on idempotency keys:
|
||||||
|
|
||||||
|
1. **Content-hash matching during remote fetch**: Scan all pending docs, read each file, hash it, and compare against incoming remote document. Failed because: (a) local content can be modified between the create and the fetch, so hashes don't match; (b) O(pending × remote) file I/O; (c) the `resolvedDocuments` getter was used instead of `pendingDocuments`, which filtered out all pending docs — a silent no-op bug.
|
||||||
|
|
||||||
|
2. **`originalCreationPath` matching**: Track where each pending doc was originally created. When a remote doc arrives at that path, assign metadata. Failed because: (a) two different clients can create at the same path — false matches assign wrong metadata, causing 3-way merge errors on the other client; (b) adding a `deviceId` check to limit false matches broke the case where another client updated the document (changing the deviceId in the broadcast).
|
||||||
|
|
||||||
|
3. **In-memory tracking** (e.g., `pendingLocalId`): Any in-memory state is lost on app crash. The whole point of the fix is to handle interrupted creates, which include crashes.
|
||||||
|
|
||||||
|
The idempotency key approach works because it's: (a) crash-safe (persisted locally); (b) deterministic (UUID lookup, no heuristics); (c) server-authoritative (the server resolves keys to documentIds).
|
||||||
|
|
||||||
|
### Critical Implementation Invariants (Learned from Bugs)
|
||||||
|
|
||||||
|
These invariants were discovered through deep auditing and E2E testing. Violating any of them causes data loss, sync stalls, or test failures.
|
||||||
|
|
||||||
|
**1. `waitUntilFinished` must loop until both sync queue AND WebSocket handlers are simultaneously idle.**
|
||||||
|
WebSocket message handlers (`onRemoteVaultUpdateReceived`) enqueue new sync operations. If you wait for the sync queue first, then WebSocket handlers, the handlers may have enqueued new operations that aren't awaited. The correct implementation loops: wait for WS handlers → wait for sync queue → check if WS has new work → repeat if needed. See `SyncClient.waitUntilFinished()`.
|
||||||
|
|
||||||
|
**2. `enqueueSyncOperation` must catch ALL errors, not just `SyncResetError`.**
|
||||||
|
`executeSync` re-throws non-SyncReset/non-FileNotFound errors (they're logged in sync history as ERROR). If `enqueueSyncOperation` doesn't catch these, they become unhandled promise rejections that crash the process. The catch logs the error and returns undefined — failed operations will be retried on the next WebSocket reconnect (which clears `runningScheduleSyncForOfflineChanges` and triggers a fresh filesystem scan).
|
||||||
|
|
||||||
|
**3. `Locks.reset()` must NOT clear `this.locked`.**
|
||||||
|
In-flight operations (currently executing their callback) still hold conceptual locks. If `reset()` clears `this.locked`, new operations can acquire the same key and run concurrently with the still-running old operation. Only clear `this.waiters` (to reject pending waiters with SyncResetError). Let running operations release their locks naturally via the `finally` block in `withLock`.
|
||||||
|
|
||||||
|
**4. `handleMaybeMergingResponse` must write the file BEFORE updating metadata.**
|
||||||
|
If metadata is updated first and the write fails (crash, OS error), the metadata points to a server version whose content was never written locally. On recovery, the stale local content is uploaded, potentially overwriting other clients' changes that were part of the merge. Order: write file → re-read + re-hash → update metadata → update cache.
|
||||||
|
|
||||||
|
**5. After a MergingUpdate, cache the SERVER's content (`responseBytes`), not the local content.**
|
||||||
|
The content cache is used to compute diffs for subsequent updates: `diff(cached, newFileContent)`. The server applies this diff against its content at `parentVersionId`. If the cache stores the local content (which may differ from the server's due to the 3-way merge in `FileOperations.write`), the diff won't match the server's state and the update will fail with "Invalid diff".
|
||||||
|
|
||||||
|
**6. After a MergingUpdate, re-read the file and re-hash.**
|
||||||
|
The 3-way merge in `operations.write()` may produce content different from `responseBytes` (because the user edited the file between the read and the write). The stored hash must match the actual on-disk content, not the server's merged content. Otherwise, the next sync cycle incorrectly detects "no changes" (phantom hash match) or always detects changes (phantom hash mismatch).
|
||||||
|
|
||||||
|
**7. Snapshot `parentVersionId` before computing diffs.**
|
||||||
|
`document.metadata` is a mutable shared reference. A concurrent operation (via a WebSocket handler running during an `await` in the same sync operation) can update `parentVersionId` between the cache lookup and the `putText` call. Always capture `const parentVersionIdForUpdate = document.metadata.parentVersionId` and use that value for both the cache lookup and the HTTP request.
|
||||||
|
|
||||||
|
**8. Guard `updateDocumentMetadata` against concurrently removed documents.**
|
||||||
|
After any `await` (file write, re-read, HTTP call), the document may have been removed from the database by a concurrent delete operation. Always check `database.containsDocument(document)` before calling `updateDocumentMetadata` if there was an `await` since the document reference was obtained. Return gracefully if removed — the file is on disk and `scheduleSyncForOfflineChanges` will re-detect it.
|
||||||
|
|
||||||
|
**9. When assigning a `documentId` to a pending doc, check for duplicates first.**
|
||||||
|
Both `resolveIdempotencyKeys` and `handleMaybeMergingResponse` (for deleted pending docs) assign documentIds. Before setting metadata, call `getDocumentByDocumentId(id)`. If another document already has that ID, remove the stale pending doc instead of creating a duplicate. `ensureConsistency` checks for duplicate documentIds across ALL documents (not just `resolvedDocuments`).
|
||||||
|
|
||||||
|
**10. `resolveIdempotencyKeys` sets `parentVersionId: 0` — treat this as a create, not an update.**
|
||||||
|
When `resolveIdempotencyKeys` assigns a documentId to a pending doc, it uses `parentVersionId: 0` as a placeholder. The sync path must check for `parentVersionId === 0` and take the CREATE path (sending a create with the idempotency key), not the UPDATE path (which would fail because version 0 doesn't exist on the server).
|
||||||
|
|
||||||
|
**11. Idempotent create returns can have stale content — always fetch server content.**
|
||||||
|
When the server returns a `FastForwardUpdate` for a create with an idempotency key, it may return the ORIGINAL version (from the first create), not a new version with the current content. Always fetch the actual server content for idempotent create returns (the `isCreate` path in `handleMaybeMergingResponse`) and use it for the cache and hash, so subsequent diffs are correct. Do not use a content-length comparison as a shortcut — two different byte sequences can have the same length.
|
||||||
|
|
||||||
|
**12. `SyncClient.pause()` must swallow `SyncResetError`.**
|
||||||
|
`pause()` calls `fetchController.startReset()` which rejects in-flight fetches. Those rejections propagate through `waitUntilFinished()`. Since `pause()` CAUSED the reset, the resulting `SyncResetError` is expected and must be caught (not re-thrown). Only re-throw non-SyncResetError exceptions. Also call `fetchController.finishReset()` in the catch block to prevent the FetchController from getting stuck in resetting state.
|
||||||
|
|
||||||
|
**13. `runningScheduleSyncForOfflineChanges` must be cleared on WebSocket disconnect.**
|
||||||
|
After the initial `scheduleSyncForOfflineChanges()` completes, the field retains the resolved promise. On WebSocket disconnect/reconnect (without a full client reset), the field must be cleared so the next call triggers a fresh filesystem scan. Add a handler on `onWebSocketStatusChanged` that sets the field to `undefined` when `isConnected` is false.
|
||||||
|
|
||||||
|
**14. The server must not `expect()` / panic on UTF-8 conversion — return a client error.**
|
||||||
|
In `update_text`, the parent version's content may be binary (if another client uploaded binary via `putBinary`). Using `.expect()` on `str::from_utf8()` panics the server. Use `.context(...).map_err(client_error)?` to return a 4xx error, allowing the client to fall back to `putBinary`.
|
||||||
|
|
||||||
|
**15. The create-merge parent content must be empty (`&Vec::new()`), not `latest_version.content`.**
|
||||||
|
In `create_document.rs`, when a create merges with an existing document, the 3-way merge parent must be an empty vector (`&Vec::new()`), not the latest version's content. Using `latest_version.content` as the parent makes `reconcile(A, A, B) = B`, which silently discards the existing content (last-write-wins). An empty parent causes `reconcile("", existing, new)` to correctly treat both sides as independent additions and merge them together.
|
||||||
|
|
||||||
|
**16. `retryForever` must not retry 4xx HTTP errors.**
|
||||||
|
4xx errors indicate the request itself is wrong (e.g., invalid diff, missing parent version). Retrying won't help. The `HttpClientError` class (in `errors/http-client-error.ts`) carries the status code. `retryForever` checks for it and re-throws immediately. Only 5xx errors (transient server failures) are retried.
|
||||||
|
|
||||||
|
**17. The broadcast channel's `RecvError::Lagged` must be handled explicitly.**
|
||||||
|
The `while let Ok(update) = broadcast_receiver.recv().await` pattern silently exits the loop on `Lagged`, disconnecting the client without logging. Handle `Lagged` explicitly with a `warn!` log and `break`. The channel capacity is `max_clients_per_vault`.
|
||||||
|
|
||||||
|
**18. `merge_with_stored_version` must not short-circuit when an idempotency key is provided.**
|
||||||
|
When the new content is identical to the latest version and an `idempotency_key` is present, the function must still insert a new version row so the key is persisted in the database. Without this, the key is lost: `resolveIdempotencyKeys` returns no match after a crash, and the client retries the create without idempotency protection — potentially doubling content via the empty-parent merge. The short-circuit (`content == latest_version.content && ... && idempotency_key.is_none()`) only applies to keyless updates.
|
||||||
|
|
||||||
|
**19. The idempotency key check in `create_document` must skip deleted documents.**
|
||||||
|
When `get_document_by_idempotency_key` returns a document with `is_deleted: true`, the server must NOT return it as an idempotent match. Returning a deleted version causes the client to call `applyRemoteDeleteLocally`, silently deleting the user's local file. Instead, fall through to the normal create path so the file is preserved as a new document.
|
||||||
|
|
||||||
|
**20. `syncLocallyCreatedFile` must treat `parentVersionId === 0` as needing a create retry.**
|
||||||
|
When `resolveIdempotencyKeys` assigns metadata with `parentVersionId: 0`, the document looks "resolved" to `syncLocallyCreatedFile` (it has `metadata !== undefined`). Without a special check for `parentVersionId === 0`, the method returns early ("already exists with metadata"), leaving the document permanently stuck — it never syncs. The fix: when `parentVersionId === 0`, treat it like a pending create retry and enqueue `unrestrictedSyncLocallyCreatedOrUpdatedFile`.
|
||||||
|
|
||||||
|
**21. The client must normalize content to UTF-8 at the read boundary.**
|
||||||
|
`FileOperations.read()` calls `normalizeToUtf8()` to transcode UTF-16 (detected by BOM) to UTF-8 before any downstream code sees the bytes. This means `isBinary` / `is_binary` on both client and server only need to check UTF-8 validity — no UTF-16 handling required. A disagreement between client and server on text vs binary causes permanent sync failures (client sends `putText` for content the server considers binary, 4xx error, `retryForever` won't retry). The UTF-8-only contract keeps classification trivial and impossible to get out of sync.
|
||||||
|
|
||||||
|
### E2E Test Debugging Guide
|
||||||
|
|
||||||
|
**How to run E2E tests:**
|
||||||
|
```bash
|
||||||
|
cd sync-server && rm -rf databases && ./target/release/sync_server config-e2e.yml &
|
||||||
|
sleep 3
|
||||||
|
cd /volumes/syncthing/Projects/vault-link && scripts/e2e.sh 8
|
||||||
|
```
|
||||||
|
Always clean the `databases` directory before running. The server must be running separately.
|
||||||
|
|
||||||
|
**Common E2E failure patterns:**
|
||||||
|
|
||||||
|
1. **`SyncResetError` unhandled rejection**: Check that `enqueueSyncOperation` catches all errors and that `pause()` swallows SyncResetError. The test client's `unhandledRejection` handler checks `error.name === "SyncResetError"` — if the error message changes, update the filter in `test-client/src/cli.ts`.
|
||||||
|
|
||||||
|
2. **"Files from agent-X missing in agent-Y"**: This is a consistency assertion. Check the agent's LOCAL file list (now correctly logged per-agent after a logging bug fix). Common causes:
|
||||||
|
- **Broadcasts lost during shutdown**: Operations completed on one agent but the WebSocket broadcast didn't reach the other before destroy. The 5-second sleep between finish and destroy helps.
|
||||||
|
- **Path deconfliction**: Both agents have the same DOCUMENT but at different LOCAL paths (e.g., `binary-10.bin` vs `binary-10 (1).bin`). This is a known limitation with concurrent creates at the same path.
|
||||||
|
- **Failed sync operations not retried**: If `executeSync` throws, the failed file won't be retried until the next WebSocket reconnect (which clears `runningScheduleSyncForOfflineChanges` and triggers a fresh filesystem scan).
|
||||||
|
|
||||||
|
3. **"Document not found in database"**: A concurrent operation removed the document between the last `await` and the `updateDocumentMetadata` call. Add a `containsDocument` guard.
|
||||||
|
|
||||||
|
4. **"Duplicate documentId found in database"**: Two documents have the same `documentId`. Usually caused by `resolveIdempotencyKeys` or `handleMaybeMergingResponse` assigning a documentId without checking if another doc already has it.
|
||||||
|
|
||||||
|
5. **"Invalid diff: attempting to access N characters..."**: The content cache has wrong content for a `parentVersionId`. Common causes: (a) cached local content instead of server content after MergingUpdate; (b) idempotent create returned a stale version but the client cached its current content under that version ID; (c) `parentVersionId` changed between cache lookup and `putText` call due to mutable shared reference.
|
||||||
|
|
||||||
|
6. **"Parent version with id 0 not found"**: A document's `parentVersionId` is 0 (set by `resolveIdempotencyKeys`). The sync path should treat `parentVersionId === 0` as a create, not an update.
|
||||||
|
|
||||||
|
**Test client internals (`test-client/src/agent/mock-agent.ts`):**
|
||||||
|
- `files`: InMemoryFileSystem map — the ACTUAL filesystem state
|
||||||
|
- `data`: Map of expected file contents — what the agent CREATED/UPDATED
|
||||||
|
- `assertFileSystemsAreConsistent`: Compares `files` maps between two agents
|
||||||
|
- `assertAllContentIsPresentOnce`: Checks no duplicate content across files
|
||||||
|
- The `finish()` and `destroy()` methods use `withTimeout(TIMEOUT_MS)` — operations that exceed 30s are killed
|
||||||
|
|
||||||
|
**Logging bug (fixed):** In `assertFileSystemsAreConsistent`, the error handler's "Local files" log previously printed `otherAgent.files.keys()` for BOTH agents. Now correctly prints `this.files.keys()` for the current agent.
|
||||||
|
|
|
||||||
24
README.md
24
README.md
|
|
@ -8,12 +8,12 @@
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
### Install [nvm](https://github.com/nvm-sh/nvm)
|
### Set up Node.JS 25 with [nvm](https://github.com/nvm-sh/nvm)
|
||||||
|
|
||||||
- `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash`
|
- `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash`
|
||||||
- `nvm install 22`
|
- `nvm install 25`
|
||||||
- `nvm use 22`
|
- `nvm use 25`
|
||||||
- Optionally set the system-wide default: `nvm alias default 22`
|
- Optionally, set the system-wide default: `nvm alias default 25`
|
||||||
|
|
||||||
### Set up Rust
|
### Set up Rust
|
||||||
|
|
||||||
|
|
@ -46,34 +46,38 @@ npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Scripts
|
### Common Tasks
|
||||||
|
|
||||||
|
This project uses [Taskfile](https://taskfile.dev/) for task automation. Run `task --list` to see all available tasks.
|
||||||
|
|
||||||
#### Before pushing
|
#### Before pushing
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/check.sh --fix
|
task check:fix
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Update HTTP API TS bindings
|
#### Update HTTP API TS bindings
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/update-api-types.sh
|
task update-api-types
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Publish new version
|
#### Publish new version
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/bump-version.sh patch
|
task release:bump -- patch
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Run E2E tests
|
#### Run E2E tests
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
scripts/e2e.sh 8
|
task e2e -- 8
|
||||||
```
|
```
|
||||||
|
|
||||||
And to clean up the logs & database files, run `scripts/clean-up.sh`
|
And to clean up the logs & database files, run `task clean`
|
||||||
|
|
||||||
## Projects
|
## Projects
|
||||||
|
|
||||||
- [Sync server](./sync-server/README.md)
|
- [Sync server](./sync-server/README.md)
|
||||||
|
|
||||||
|
remove force merge everywhere
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,7 @@
|
||||||
"version": "0.2",
|
"version": "0.2",
|
||||||
"language": "en-GB",
|
"language": "en-GB",
|
||||||
"dictionaries": ["en-gb"],
|
"dictionaries": ["en-gb"],
|
||||||
"ignorePaths": [
|
"ignorePaths": ["node_modules", ".vitepress/dist", ".vitepress/cache", "package-lock.json"],
|
||||||
"node_modules",
|
|
||||||
".vitepress/dist",
|
|
||||||
".vitepress/cache",
|
|
||||||
"package-lock.json"
|
|
||||||
],
|
|
||||||
"words": [
|
"words": [
|
||||||
"VaultLink",
|
"VaultLink",
|
||||||
"Obsidian",
|
"Obsidian",
|
||||||
|
|
|
||||||
|
|
@ -361,11 +361,11 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "upload_file",
|
"type": "upload_file",
|
||||||
"path": "notes/example.md",
|
"path": "notes/example.md",
|
||||||
"content": "File content here...",
|
"content": "File content here...",
|
||||||
"base_version": 10,
|
"base_version": 10,
|
||||||
"timestamp": "2024-01-01T12:00:00Z"
|
"timestamp": "2024-01-01T12:00:00Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -373,8 +373,8 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "download_file",
|
"type": "download_file",
|
||||||
"path": "notes/example.md"
|
"path": "notes/example.md"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -382,8 +382,8 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "delete_file",
|
"type": "delete_file",
|
||||||
"path": "notes/old.md"
|
"path": "notes/old.md"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -391,8 +391,8 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "list_files",
|
"type": "list_files",
|
||||||
"since_version": 0
|
"since_version": 0
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -402,11 +402,11 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "file_updated",
|
"type": "file_updated",
|
||||||
"path": "notes/example.md",
|
"path": "notes/example.md",
|
||||||
"version": 11,
|
"version": 11,
|
||||||
"size": 1024,
|
"size": 1024,
|
||||||
"hash": "abc123..."
|
"hash": "abc123..."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -414,10 +414,10 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "file_content",
|
"type": "file_content",
|
||||||
"path": "notes/example.md",
|
"path": "notes/example.md",
|
||||||
"content": "Updated content...",
|
"content": "Updated content...",
|
||||||
"version": 11
|
"version": 11
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -425,9 +425,9 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "file_deleted",
|
"type": "file_deleted",
|
||||||
"path": "notes/old.md",
|
"path": "notes/old.md",
|
||||||
"version": 12
|
"version": 12
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -435,9 +435,9 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "sync_complete",
|
"type": "sync_complete",
|
||||||
"total_files": 150,
|
"total_files": 150,
|
||||||
"current_version": 200
|
"current_version": 200
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -445,9 +445,9 @@ VALUES (?, ?, ?);
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"message": "File too large",
|
"message": "File too large",
|
||||||
"code": "FILE_TOO_LARGE"
|
"code": "FILE_TOO_LARGE"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ Central authority for synchronisation. Rust + Axum framework.
|
||||||
|
|
||||||
**Technology**:
|
**Technology**:
|
||||||
|
|
||||||
- **Language**: Rust 1.89+
|
- **Language**: Rust 1.92+
|
||||||
- **Framework**: Axum (async web framework)
|
- **Framework**: Axum (async web framework)
|
||||||
- **Database**: SQLite with SQLx
|
- **Database**: SQLite with SQLx
|
||||||
- **Protocol**: WebSockets for real-time communication
|
- **Protocol**: WebSockets for real-time communication
|
||||||
|
|
|
||||||
|
|
@ -243,9 +243,9 @@ users:
|
||||||
2. Client sends authentication message:
|
2. Client sends authentication message:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "auth",
|
"type": "auth",
|
||||||
"token": "user-token",
|
"token": "user-token",
|
||||||
"vault": "vault-name"
|
"vault": "vault-name"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
3. Server validates:
|
3. Server validates:
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ chmod +x sync_server-linux-x86_64
|
||||||
|
|
||||||
### Build from Source
|
### Build from Source
|
||||||
|
|
||||||
Requirements: Rust 1.89.0+, SQLite development headers, SQLx CLI
|
Requirements: Rust 1.92.0+, SQLite development headers, SQLx CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
# Clone the repository
|
||||||
|
|
|
||||||
5960
docs/package-lock.json
generated
5960
docs/package-lock.json
generated
File diff suppressed because it is too large
Load diff
283
frontend/deterministic-tests/README.md
Normal file
283
frontend/deterministic-tests/README.md
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
# Deterministic Testing Framework
|
||||||
|
|
||||||
|
A framework for defining and running deterministic tests for VaultLink sync operations. Unlike the fuzz testing approach, these tests execute exact sequences of operations to verify specific conflict resolution scenarios.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The deterministic testing framework allows you to:
|
||||||
|
|
||||||
|
- Define exact sequences of client operations in TypeScript
|
||||||
|
- Control both client and server processes (pause/resume)
|
||||||
|
- Test specific conflict scenarios (write/write, rename/create, etc.)
|
||||||
|
- Verify that the system resolves conflicts consistently
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Test Definition (TypeScript) │
|
||||||
|
│ - Declare steps sequentially │
|
||||||
|
│ - Specify client operations │
|
||||||
|
│ - Add assertions │
|
||||||
|
└──────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
v
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Test Runner │
|
||||||
|
│ - Initializes clients │
|
||||||
|
│ - Executes steps in order │
|
||||||
|
│ - Manages server lifecycle │
|
||||||
|
└──────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─→ DeterministicAgent (per client)
|
||||||
|
│ └─→ SyncClient
|
||||||
|
│
|
||||||
|
└─→ ServerControl
|
||||||
|
└─→ sync_server process
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Definition Format
|
||||||
|
|
||||||
|
Tests are defined using the `TestDefinition` interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TestDefinition {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
clients: number;
|
||||||
|
steps: TestStep[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Steps
|
||||||
|
|
||||||
|
#### File Operations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ type: "create", client: 0, path: "file.md", content: "hello" }
|
||||||
|
{ type: "update", client: 0, path: "file.md", content: "world" }
|
||||||
|
{ type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }
|
||||||
|
{ type: "delete", client: 0, path: "file.md" }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sync Control
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ type: "sync", client: 0 } // Wait for specific client
|
||||||
|
{ type: "sync" } // Wait for all clients
|
||||||
|
{ type: "barrier" } // Wait for all pending ops
|
||||||
|
{ type: "disable-sync", client: 0 }
|
||||||
|
{ type: "enable-sync", client: 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Server Control
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ type: "pause-server" } // Pause server process
|
||||||
|
{ type: "resume-server" } // Resume server process
|
||||||
|
{ type: "wait", duration: 500 } // Wait N milliseconds
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Assertions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ type: "assert-content", client: 0, path: "file.md", content: "hello" }
|
||||||
|
{ type: "assert-exists", client: 0, path: "file.md" }
|
||||||
|
{ type: "assert-not-exists", client: 0, path: "file.md" }
|
||||||
|
{ type: "assert-consistent" } // All clients have same state
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Tests
|
||||||
|
|
||||||
|
### Write/Write Conflict
|
||||||
|
|
||||||
|
Two clients create the same file with different content:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const writeWriteConflictTest: TestDefinition = {
|
||||||
|
name: "Write/Write Conflict",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "disable-sync", client: 0 },
|
||||||
|
{ type: "disable-sync", client: 1 },
|
||||||
|
{ type: "create", client: 0, path: "A.md", content: "hello" },
|
||||||
|
{ type: "create", client: 1, path: "A.md", content: "world" },
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
{ type: "wait", duration: 500 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
{ type: "assert-consistent" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rename/Create Conflict
|
||||||
|
|
||||||
|
Client 1 renames A→B while Client 0 creates B:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const renameCreateConflictTest: TestDefinition = {
|
||||||
|
name: "Rename-Create Conflict",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "create", client: 0, path: "A.md", content: "hi" },
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
{ type: "disable-sync", client: 0 },
|
||||||
|
{ type: "create", client: 0, path: "B.md", content: "hi" },
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
{ type: "wait", duration: 500 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
{ type: "assert-consistent" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Build and Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From frontend/deterministic-tests
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Specific Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- --test write-write-conflict
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Available Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- --list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Options
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use custom server binary
|
||||||
|
npm run test -- --server /path/to/sync_server
|
||||||
|
|
||||||
|
# Use custom config
|
||||||
|
npm run test -- --config /path/to/config.yml
|
||||||
|
|
||||||
|
# Don't manage server (assume it's already running)
|
||||||
|
npm run test -- --no-manage-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating New Tests
|
||||||
|
|
||||||
|
1. Create a new test file in `src/tests/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// my-test.test.ts
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const myTest: TestDefinition = {
|
||||||
|
name: "My Test",
|
||||||
|
description: "What this test verifies",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
// Your test steps here
|
||||||
|
]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Register the test in `src/cli.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { myTest } from "./tests/my-test.test";
|
||||||
|
|
||||||
|
const TESTS: Record<string, TestDefinition> = {
|
||||||
|
// ... existing tests
|
||||||
|
"my-test": myTest
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test -- --test my-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Concepts
|
||||||
|
|
||||||
|
### Synchronization Points
|
||||||
|
|
||||||
|
Use explicit sync barriers to ensure operations complete:
|
||||||
|
|
||||||
|
- `{ type: "sync", client: 0 }` - Wait for client 0 to finish pending ops
|
||||||
|
- `{ type: "barrier" }` - Wait for all clients to finish
|
||||||
|
- `{ type: "wait", duration: 500 }` - Wait for propagation
|
||||||
|
|
||||||
|
### Offline Testing
|
||||||
|
|
||||||
|
Disable sync to simulate offline edits:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ type: "disable-sync", client: 0 },
|
||||||
|
{ type: "create", client: 0, path: "file.md", content: "offline edit" },
|
||||||
|
{ type: "enable-sync", client: 0 }, // Sync when back online
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Control
|
||||||
|
|
||||||
|
Pause the server to test reconnection:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{ type: "pause-server" },
|
||||||
|
{ type: "create", client: 0, path: "file.md", content: "while paused" },
|
||||||
|
{ type: "resume-server" },
|
||||||
|
{ type: "barrier" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Assertions
|
||||||
|
|
||||||
|
Always end tests with consistency checks:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: "assert-consistent";
|
||||||
|
} // Verify all clients converged
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Server Won't Start
|
||||||
|
|
||||||
|
- Ensure server is built: `cd sync-server && cargo build`
|
||||||
|
- Check config file exists: `sync-server/config-e2e.yml`
|
||||||
|
- Verify port 3000 is available
|
||||||
|
|
||||||
|
### Test Hangs
|
||||||
|
|
||||||
|
- Increase wait durations for slow systems
|
||||||
|
- Add more `{ type: "barrier" }` steps
|
||||||
|
- Check server logs for errors
|
||||||
|
|
||||||
|
### Assertion Failures
|
||||||
|
|
||||||
|
- Add `{ type: "wait", duration: 1000 }` before assertions
|
||||||
|
- Check if conflict resolution is working as expected
|
||||||
|
- Review test steps for logic errors
|
||||||
|
|
||||||
|
## Comparison to Fuzz Tests
|
||||||
|
|
||||||
|
| Aspect | Fuzz Tests | Deterministic Tests |
|
||||||
|
| --------------- | --------------- | ------------------------- |
|
||||||
|
| Operations | Random | Explicit sequence |
|
||||||
|
| Reproducibility | Difficult | Perfect |
|
||||||
|
| Coverage | Broad | Targeted |
|
||||||
|
| Debugging | Hard | Easy |
|
||||||
|
| Use Case | Find edge cases | Verify specific scenarios |
|
||||||
|
|
||||||
|
Use both approaches:
|
||||||
|
|
||||||
|
- Fuzz tests for discovering unexpected issues
|
||||||
|
- Deterministic tests for verifying specific fixes
|
||||||
22
frontend/deterministic-tests/package.json
Normal file
22
frontend/deterministic-tests/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "deterministic-tests",
|
||||||
|
"version": "0.14.0",
|
||||||
|
"private": true,
|
||||||
|
"bin": {
|
||||||
|
"deterministic-tests": "./dist/cli.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "webpack watch --mode development",
|
||||||
|
"build": "webpack --mode production",
|
||||||
|
"test": "npm run build && node dist/cli.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.0.2",
|
||||||
|
"sync-client": "file:../sync-client",
|
||||||
|
"ts-loader": "^9.5.4",
|
||||||
|
"tslib": "2.8.1",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"webpack": "^5.103.0",
|
||||||
|
"webpack-cli": "^6.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
105
frontend/deterministic-tests/src/cli.ts
Normal file
105
frontend/deterministic-tests/src/cli.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { TestRunner } from "./test-runner";
|
||||||
|
import { ServerControl } from "./server-control";
|
||||||
|
import type { TestDefinition } from "./test-definition";
|
||||||
|
import { writeWriteConflictTest } from "./tests/write-write-conflict.test";
|
||||||
|
import { renameCreateConflictTest } from "./tests/rename-create-conflict.test";
|
||||||
|
import { TOKEN, REMOTE_URI, SERVER_BINARY_PATH, CONFIG_PATH } from "./consts";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import { debugging, Logger } from "sync-client";
|
||||||
|
|
||||||
|
const logger = new Logger();
|
||||||
|
debugging.logToConsole(logger, { useColors: true });
|
||||||
|
|
||||||
|
process.on("unhandledRejection", (reason) => {
|
||||||
|
logger.error(`Unhandled Rejection: ${reason}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
logger.error(`Uncaught Exception: ${error}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const TESTS: Partial<Record<string, TestDefinition>> = {
|
||||||
|
// "write-write-conflict": writeWriteConflictTest,
|
||||||
|
"rename-create-conflict": renameCreateConflictTest
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
let projectRoot = cwd;
|
||||||
|
|
||||||
|
if (cwd.endsWith("frontend/deterministic-tests")) {
|
||||||
|
projectRoot = path.resolve(cwd, "../..");
|
||||||
|
} else if (cwd.endsWith("frontend")) {
|
||||||
|
projectRoot = path.resolve(cwd, "..");
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverPath = path.join(projectRoot, SERVER_BINARY_PATH);
|
||||||
|
if (!fs.existsSync(serverPath)) {
|
||||||
|
logger.error(`Server binary not found at: ${serverPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = path.join(projectRoot, CONFIG_PATH);
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
logger.error(`Config file not found at: ${configPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const testsToRun: TestDefinition[] = [];
|
||||||
|
for (const test of Object.values(TESTS)) {
|
||||||
|
if (test) {
|
||||||
|
testsToRun.push(test);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Server: ${serverPath}`);
|
||||||
|
logger.info(`Config: ${configPath}`);
|
||||||
|
logger.info(`Tests to run: ${testsToRun.length}`);
|
||||||
|
|
||||||
|
const serverControl = new ServerControl(serverPath, configPath, logger);
|
||||||
|
|
||||||
|
let allPassed = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await serverControl.start();
|
||||||
|
await serverControl.waitForReady();
|
||||||
|
|
||||||
|
for (const test of testsToRun) {
|
||||||
|
const runner = new TestRunner(
|
||||||
|
serverControl,
|
||||||
|
logger,
|
||||||
|
TOKEN,
|
||||||
|
REMOTE_URI
|
||||||
|
);
|
||||||
|
const result = await runner.runTest(test);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
allPassed = false;
|
||||||
|
logger.error(`✗ FAILED: ${test.name}`);
|
||||||
|
logger.error(`Error: ${result.error}`);
|
||||||
|
} else {
|
||||||
|
logger.info(`✓ PASSED: ${test.name} (${result.duration}ms)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await serverControl.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allPassed) {
|
||||||
|
logger.info("✓ All tests passed!");
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
logger.info("✗ Some tests failed");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err: unknown) => {
|
||||||
|
logger.error(`Unexpected error: ${err}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
5
frontend/deterministic-tests/src/consts.ts
Normal file
5
frontend/deterministic-tests/src/consts.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export const TOKEN = "test-token-change-me ";
|
||||||
|
export const REMOTE_URI = "http://localhost:3010";
|
||||||
|
export const PING_URL = `${REMOTE_URI}/vaults/test/ping`;
|
||||||
|
export const SERVER_BINARY_PATH = "sync-server/target/debug/sync_server";
|
||||||
|
export const CONFIG_PATH = "sync-server/config-e2e.yml";
|
||||||
178
frontend/deterministic-tests/src/deterministic-agent.ts
Normal file
178
frontend/deterministic-tests/src/deterministic-agent.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import type { StoredDatabase, SyncSettings, RelativePath } from "sync-client";
|
||||||
|
import { SyncClient, debugging } from "sync-client";
|
||||||
|
import { assert } from "./utils/assert";
|
||||||
|
|
||||||
|
export class DeterministicAgent extends debugging.InMemoryFileSystem {
|
||||||
|
public readonly clientId: number;
|
||||||
|
private readonly logger: (msg: string) => void;
|
||||||
|
private client!: SyncClient;
|
||||||
|
private data: Partial<{
|
||||||
|
settings: Partial<SyncSettings>;
|
||||||
|
database: Partial<StoredDatabase>;
|
||||||
|
}> = {};
|
||||||
|
private isSyncEnabled = true;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
clientId: number,
|
||||||
|
initialSettings: Partial<SyncSettings>,
|
||||||
|
logger: (msg: string) => void
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.logger = logger;
|
||||||
|
this.data.settings = initialSettings;
|
||||||
|
this.isSyncEnabled = initialSettings.isSyncEnabled !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async init(
|
||||||
|
fetchImplementation: typeof globalThis.fetch,
|
||||||
|
webSocketImplementation: typeof globalThis.WebSocket
|
||||||
|
): Promise<void> {
|
||||||
|
this.client = await SyncClient.create({
|
||||||
|
fs: this,
|
||||||
|
persistence: {
|
||||||
|
load: async () => this.data,
|
||||||
|
save: async (data) => void (this.data = data)
|
||||||
|
},
|
||||||
|
fetch: fetchImplementation,
|
||||||
|
webSocket: webSocketImplementation
|
||||||
|
});
|
||||||
|
|
||||||
|
debugging.logToConsole(this.client.logger, { useColors: true });
|
||||||
|
|
||||||
|
await this.client.start();
|
||||||
|
|
||||||
|
const connectionCheck = await this.client.checkConnection();
|
||||||
|
assert(
|
||||||
|
connectionCheck.isSuccessful,
|
||||||
|
`Client ${this.clientId} connection check failed`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createFile(path: string, content: string): Promise<void> {
|
||||||
|
this.log(`Creating file ${path} with content: ${content}`);
|
||||||
|
if (this.files.has(path)) {
|
||||||
|
throw new Error(`File ${path} already exists`);
|
||||||
|
}
|
||||||
|
const contentBytes = new TextEncoder().encode(content);
|
||||||
|
this.files.set(path, contentBytes);
|
||||||
|
|
||||||
|
if (this.isSyncEnabled) {
|
||||||
|
await this.client.syncLocallyCreatedFile(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateFile(path: string, content: string): Promise<void> {
|
||||||
|
this.log(`Updating file ${path} with content: ${content}`);
|
||||||
|
const contentBytes = new TextEncoder().encode(content);
|
||||||
|
this.files.set(path, contentBytes);
|
||||||
|
|
||||||
|
if (this.isSyncEnabled) {
|
||||||
|
await this.client.syncLocallyUpdatedFile({ relativePath: path });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async renameFile(oldPath: string, newPath: string): Promise<void> {
|
||||||
|
this.log(`Renaming file ${oldPath} to ${newPath}`);
|
||||||
|
const file = this.files.get(oldPath);
|
||||||
|
if (!file) {
|
||||||
|
throw new Error(`File ${oldPath} does not exist`);
|
||||||
|
}
|
||||||
|
this.files.set(newPath, file);
|
||||||
|
if (oldPath !== newPath) {
|
||||||
|
this.files.delete(oldPath);
|
||||||
|
}
|
||||||
|
if (this.isSyncEnabled) {
|
||||||
|
await this.client.syncLocallyUpdatedFile({
|
||||||
|
oldPath,
|
||||||
|
relativePath: newPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteFile(path: string): Promise<void> {
|
||||||
|
this.log(`Deleting file ${path}`);
|
||||||
|
this.files.delete(path);
|
||||||
|
if (this.isSyncEnabled) {
|
||||||
|
await this.client.syncLocallyDeletedFile(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async waitForSync(): Promise<void> {
|
||||||
|
this.log("Waiting for sync to complete...");
|
||||||
|
await this.client.waitUntilFinished();
|
||||||
|
this.log("Sync complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disableSync(): Promise<void> {
|
||||||
|
this.log("Disabling sync");
|
||||||
|
this.isSyncEnabled = false;
|
||||||
|
await this.client.setSetting("isSyncEnabled", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async enableSync(): Promise<void> {
|
||||||
|
this.log("Enabling sync");
|
||||||
|
this.isSyncEnabled = true;
|
||||||
|
await this.client.setSetting("isSyncEnabled", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async assertContent(
|
||||||
|
path: string,
|
||||||
|
expectedContent: string
|
||||||
|
): Promise<void> {
|
||||||
|
this.log(`Asserting content of ${path} equals "${expectedContent}"`);
|
||||||
|
const exists = await this.exists(path);
|
||||||
|
assert(
|
||||||
|
exists,
|
||||||
|
`File ${path} does not exist on client ${this.clientId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const actualBytes = await this.read(path);
|
||||||
|
const actualContent = new TextDecoder().decode(actualBytes);
|
||||||
|
assert(
|
||||||
|
actualContent === expectedContent,
|
||||||
|
`Content mismatch on client ${this.clientId} for ${path}:\nExpected: "${expectedContent}"\nActual: "${actualContent}"`
|
||||||
|
);
|
||||||
|
this.log(`✓ Content assertion passed for ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async assertExists(path: string): Promise<void> {
|
||||||
|
this.log(`Asserting ${path} exists`);
|
||||||
|
const exists = await this.exists(path);
|
||||||
|
assert(
|
||||||
|
exists,
|
||||||
|
`File ${path} does not exist on client ${this.clientId}`
|
||||||
|
);
|
||||||
|
this.log(`✓ File ${path} exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async assertNotExists(path: string): Promise<void> {
|
||||||
|
this.log(`Asserting ${path} does not exist`);
|
||||||
|
const exists = await this.exists(path);
|
||||||
|
assert(
|
||||||
|
!exists,
|
||||||
|
`File ${path} exists on client ${this.clientId} but should not`
|
||||||
|
);
|
||||||
|
this.log(`✓ File ${path} does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getFiles(): Promise<RelativePath[]> {
|
||||||
|
return this.listFilesRecursively();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getFileContent(path: string): Promise<string> {
|
||||||
|
const bytes = await this.read(path);
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cleanup(): Promise<void> {
|
||||||
|
this.log("Cleaning up...");
|
||||||
|
await this.client.waitUntilFinished();
|
||||||
|
await this.client.destroy();
|
||||||
|
this.log("Cleanup complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(message: string): void {
|
||||||
|
this.logger(`[Client ${this.clientId}] ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
frontend/deterministic-tests/src/server-control.ts
Normal file
144
frontend/deterministic-tests/src/server-control.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { sleep } from "./utils/sleep";
|
||||||
|
import type { Logger } from "sync-client";
|
||||||
|
import { PING_URL } from "./consts";
|
||||||
|
|
||||||
|
export class ServerControl {
|
||||||
|
private process: ChildProcess | null = null;
|
||||||
|
private readonly serverPath: string;
|
||||||
|
private readonly configPath: string;
|
||||||
|
private readonly logger: Logger;
|
||||||
|
|
||||||
|
public constructor(serverPath: string, configPath: string, logger: Logger) {
|
||||||
|
this.serverPath = serverPath;
|
||||||
|
this.configPath = configPath;
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async start(): Promise<void> {
|
||||||
|
if (this.process !== null) {
|
||||||
|
throw new Error("Server is already running");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
`Starting server: ${this.serverPath} ${this.configPath}`
|
||||||
|
);
|
||||||
|
|
||||||
|
let startupError: string | null = null;
|
||||||
|
|
||||||
|
this.process = spawn(this.serverPath, [this.configPath], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached: false
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stdout?.on("data", (data: Buffer) => {
|
||||||
|
this.logger.info(`[SERVER] ${data.toString().trim()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.stderr?.on("data", (data: Buffer) => {
|
||||||
|
const msg = data.toString().trim();
|
||||||
|
this.logger.info(`[SERVER] ${msg}`);
|
||||||
|
if (msg.includes("Failed to") || msg.includes("Error")) {
|
||||||
|
startupError = msg;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on("error", (err) => {
|
||||||
|
this.logger.error(`[SERVER] Process error: ${err.message}`);
|
||||||
|
startupError = err.message;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on("exit", (code, signal) => {
|
||||||
|
this.logger.info(
|
||||||
|
`Server exited with code ${code}, signal ${signal}`
|
||||||
|
);
|
||||||
|
this.process = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
await sleep(100);
|
||||||
|
this.checkProcessAlive(startupError, "startup");
|
||||||
|
await this.waitForReady();
|
||||||
|
this.checkProcessAlive(startupError, "after startup");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async waitForReady(maxAttempts = 30): Promise<void> {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(PING_URL);
|
||||||
|
if (response.ok) {
|
||||||
|
this.logger.info("[SERVER] Ready");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server not ready yet, continue polling
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
throw new Error("Server failed to start within timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
public pause(): void {
|
||||||
|
if (this.process?.pid === undefined) {
|
||||||
|
throw new Error("Server is not running");
|
||||||
|
}
|
||||||
|
this.logger.info("Server pausing...");
|
||||||
|
process.kill(this.process.pid, "SIGSTOP");
|
||||||
|
}
|
||||||
|
|
||||||
|
public resume(): void {
|
||||||
|
if (this.process?.pid === undefined) {
|
||||||
|
throw new Error("Server is not running");
|
||||||
|
}
|
||||||
|
this.logger.info("Server resuming...");
|
||||||
|
process.kill(this.process.pid, "SIGCONT");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async stop(): Promise<void> {
|
||||||
|
if (this.process?.pid === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info("Server stopping...");
|
||||||
|
const { pid } = this.process;
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (this.process === null) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.process.on("exit", () => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.kill(pid, "SIGTERM");
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.process?.pid !== undefined) {
|
||||||
|
process.kill(this.process.pid, "SIGKILL");
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public isRunning(): boolean {
|
||||||
|
return this.process?.pid !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkProcessAlive(
|
||||||
|
startupError: string | null,
|
||||||
|
phase: string
|
||||||
|
): void {
|
||||||
|
const proc = this.process;
|
||||||
|
if (proc === null) {
|
||||||
|
throw new Error(
|
||||||
|
`Server process died during ${phase}: ${startupError ?? "unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (proc.exitCode !== null) {
|
||||||
|
throw new Error(
|
||||||
|
`Server process exited during ${phase}: ${startupError ?? "unknown error"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
frontend/deterministic-tests/src/test-definition.ts
Normal file
33
frontend/deterministic-tests/src/test-definition.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
export interface ClientState {
|
||||||
|
files: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TestStep =
|
||||||
|
| { type: "create"; client: number; path: string; content: string }
|
||||||
|
| { type: "update"; client: number; path: string; content: string }
|
||||||
|
| { type: "rename"; client: number; oldPath: string; newPath: string }
|
||||||
|
| { type: "delete"; client: number; path: string }
|
||||||
|
| { type: "sync"; client?: number }
|
||||||
|
| { type: "disable-sync"; client: number }
|
||||||
|
| { type: "enable-sync"; client: number }
|
||||||
|
| { type: "pause-server" }
|
||||||
|
| { type: "resume-server" }
|
||||||
|
| { type: "barrier" }
|
||||||
|
| { type: "assert-content"; client: number; path: string; content: string }
|
||||||
|
| { type: "assert-exists"; client: number; path: string }
|
||||||
|
| { type: "assert-not-exists"; client: number; path: string }
|
||||||
|
| { type: "assert-consistent"; verify?: (state: ClientState) => void };
|
||||||
|
|
||||||
|
export interface TestDefinition {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
clients: number;
|
||||||
|
steps: TestStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
stepsFailed?: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
306
frontend/deterministic-tests/src/test-runner.ts
Normal file
306
frontend/deterministic-tests/src/test-runner.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
import type {
|
||||||
|
TestDefinition,
|
||||||
|
TestResult,
|
||||||
|
TestStep,
|
||||||
|
ClientState
|
||||||
|
} from "./test-definition";
|
||||||
|
import { DeterministicAgent } from "./deterministic-agent";
|
||||||
|
import type { ServerControl } from "./server-control";
|
||||||
|
import type { SyncSettings, Logger } from "sync-client";
|
||||||
|
import { assert } from "./utils/assert";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
export class TestRunner {
|
||||||
|
private agents: DeterministicAgent[] = [];
|
||||||
|
private readonly serverControl: ServerControl;
|
||||||
|
private readonly token: string;
|
||||||
|
private readonly remoteUri: string;
|
||||||
|
private readonly logger: Logger;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
serverControl: ServerControl,
|
||||||
|
logger: Logger,
|
||||||
|
token: string,
|
||||||
|
remoteUri: string
|
||||||
|
) {
|
||||||
|
this.serverControl = serverControl;
|
||||||
|
this.logger = logger;
|
||||||
|
this.token = token;
|
||||||
|
this.remoteUri = remoteUri;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async runTest(test: TestDefinition): Promise<TestResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
this.logger.info(`Running test: ${test.name}`);
|
||||||
|
if (test.description !== undefined && test.description !== "") {
|
||||||
|
this.logger.info(`Description: ${test.description}`);
|
||||||
|
}
|
||||||
|
this.logger.info(`Clients: ${test.clients}`);
|
||||||
|
this.logger.info(`Steps: ${test.steps.length}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize agents
|
||||||
|
await this.initializeAgents(test.clients);
|
||||||
|
|
||||||
|
// Execute steps
|
||||||
|
for (let i = 0; i < test.steps.length; i++) {
|
||||||
|
const step = test.steps[i];
|
||||||
|
this.logger.info(
|
||||||
|
`Step ${i + 1}/${test.steps.length}: ${JSON.stringify(step)}`
|
||||||
|
);
|
||||||
|
await this.executeStep(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.cleanup();
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
this.logger.info(`\n✓ Test passed: ${test.name} (${duration}ms)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
duration
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.info(`\n✗ Test failed: ${test.name}`);
|
||||||
|
this.logger.info(`Error: ${errorMessage}`);
|
||||||
|
|
||||||
|
await this.cleanup();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
|
duration
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initializeAgents(count: number): Promise<void> {
|
||||||
|
const vaultName = `test-${randomUUID()}`;
|
||||||
|
this.logger.info(
|
||||||
|
`Initializing ${count} agents with vault: ${vaultName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings: Partial<SyncSettings> = {
|
||||||
|
isSyncEnabled: false,
|
||||||
|
token: this.token,
|
||||||
|
vaultName,
|
||||||
|
remoteUri: this.remoteUri
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const agent = new DeterministicAgent(i, settings, (msg) => {
|
||||||
|
this.logger.info(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
await agent.init(
|
||||||
|
fetch,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
WebSocket as unknown as typeof globalThis.WebSocket
|
||||||
|
);
|
||||||
|
this.agents.push(agent);
|
||||||
|
this.logger.info(`Initialized client ${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info("All agents initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async executeStep(step: TestStep): Promise<void> {
|
||||||
|
switch (step.type) {
|
||||||
|
case "create":
|
||||||
|
await this.agents[step.client].createFile(
|
||||||
|
step.path,
|
||||||
|
step.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "update":
|
||||||
|
await this.agents[step.client].updateFile(
|
||||||
|
step.path,
|
||||||
|
step.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "rename":
|
||||||
|
await this.agents[step.client].renameFile(
|
||||||
|
step.oldPath,
|
||||||
|
step.newPath
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "delete":
|
||||||
|
await this.agents[step.client].deleteFile(step.path);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "sync":
|
||||||
|
if (step.client !== undefined) {
|
||||||
|
await this.agents[step.client].waitForSync();
|
||||||
|
} else {
|
||||||
|
for (const agent of this.agents) {
|
||||||
|
await agent.waitForSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "disable-sync":
|
||||||
|
await this.agents[step.client].disableSync();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "enable-sync":
|
||||||
|
await this.agents[step.client].enableSync();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "pause-server":
|
||||||
|
this.serverControl.pause();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "resume-server":
|
||||||
|
this.serverControl.resume();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "barrier":
|
||||||
|
await this.waitForConvergence();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "assert-content":
|
||||||
|
await this.agents[step.client].assertContent(
|
||||||
|
step.path,
|
||||||
|
step.content
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "assert-exists":
|
||||||
|
await this.agents[step.client].assertExists(step.path);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "assert-not-exists":
|
||||||
|
await this.agents[step.client].assertNotExists(step.path);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "assert-consistent":
|
||||||
|
await this.assertConsistent(step.verify);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const unknownStep = step as { type: string };
|
||||||
|
throw new Error(`Unknown step type: ${unknownStep.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForConvergence(): Promise<void> {
|
||||||
|
this.logger.info("Barrier: waiting for convergence...");
|
||||||
|
|
||||||
|
for (const agent of this.agents) {
|
||||||
|
await agent.waitForSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await this.checkConsistency()) {
|
||||||
|
this.logger.info("Barrier complete: all clients converged");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Clients did not converge`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkConsistency(): Promise<boolean> {
|
||||||
|
const [referenceAgent] = this.agents;
|
||||||
|
const referenceFiles = (await referenceAgent.getFiles()).sort();
|
||||||
|
|
||||||
|
for (let i = 1; i < this.agents.length; i++) {
|
||||||
|
const agent = this.agents[i];
|
||||||
|
const files = (await agent.getFiles()).sort();
|
||||||
|
|
||||||
|
if (files.length !== referenceFiles.length) {
|
||||||
|
throw new Error(
|
||||||
|
`File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${files.length} files.\n Files: ${files.join(", ")}\n Reference: ${referenceFiles.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of referenceFiles) {
|
||||||
|
const referenceContent =
|
||||||
|
await referenceAgent.getFileContent(file);
|
||||||
|
const agentContent = await agent.getFileContent(file);
|
||||||
|
|
||||||
|
if (referenceContent !== agentContent) {
|
||||||
|
throw new Error(
|
||||||
|
`Content mismatch for ${file}:\nReference: "${referenceContent}"\nClient ${i}: "${agentContent}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertConsistent(
|
||||||
|
verify?: (state: ClientState) => void
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.info("Asserting all clients are consistent...");
|
||||||
|
|
||||||
|
const [referenceAgent] = this.agents;
|
||||||
|
const referenceFiles = (await referenceAgent.getFiles()).sort();
|
||||||
|
const referenceState: ClientState = { files: new Map() };
|
||||||
|
|
||||||
|
for (const file of referenceFiles) {
|
||||||
|
const content = await referenceAgent.getFileContent(file);
|
||||||
|
referenceState.files.set(file, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
`Reference client has ${referenceFiles.length} files: ${referenceFiles.join(", ")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 1; i < this.agents.length; i++) {
|
||||||
|
const agent = this.agents[i];
|
||||||
|
const files = (await agent.getFiles()).sort();
|
||||||
|
|
||||||
|
this.logger.info(
|
||||||
|
`Client ${i} has ${files.length} files: ${files.join(", ")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
files.length === referenceFiles.length,
|
||||||
|
`File count mismatch: client 0 has ${referenceFiles.length} files, client ${i} has ${files.length} files`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let j = 0; j < files.length; j++) {
|
||||||
|
assert(
|
||||||
|
files[j] === referenceFiles[j],
|
||||||
|
`File list mismatch at index ${j}: client 0 has "${referenceFiles[j]}", client ${i} has "${files[j]}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of referenceFiles) {
|
||||||
|
const referenceContent = referenceState.files.get(file);
|
||||||
|
const agentContent = await agent.getFileContent(file);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
referenceContent === agentContent,
|
||||||
|
`Content mismatch for ${file}:\nClient 0: "${referenceContent}"\nClient ${i}: "${agentContent}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info("✓ All clients are consistent");
|
||||||
|
|
||||||
|
if (verify) {
|
||||||
|
this.logger.info("Running custom verification...");
|
||||||
|
verify(referenceState);
|
||||||
|
this.logger.info("✓ Custom verification passed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanup(): Promise<void> {
|
||||||
|
this.logger.info("\nCleaning up agents...");
|
||||||
|
for (const agent of this.agents) {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
this.agents = [];
|
||||||
|
this.logger.info("Cleanup complete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
export const renameCreateConflictTest: TestDefinition = {
|
||||||
|
name: "Rename-Create Conflict",
|
||||||
|
description:
|
||||||
|
"Client 0 creates file A, Client 1 renames A to B, then Client 0 (without syncing) creates B. " +
|
||||||
|
"The system must resolve the conflict deterministically.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "create", client: 0, path: "A.md", content: "hi" },
|
||||||
|
{ type: "sync", client: 0 },
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
{ type: "assert-exists", client: 1, path: "A.md" },
|
||||||
|
{ type: "assert-content", client: 1, path: "A.md", content: "hi" },
|
||||||
|
{ type: "disable-sync", client: 0 },
|
||||||
|
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
|
||||||
|
{ type: "sync", client: 1 },
|
||||||
|
{ type: "create", client: 0, path: "B.md", content: "hi" },
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
{ type: "assert-consistent" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { ClientState, TestDefinition } from "../test-definition";
|
||||||
|
import { assert } from "../utils/assert";
|
||||||
|
|
||||||
|
function verifyMergedContent(state: ClientState): void {
|
||||||
|
assert(state.files.size === 1, `Expected 1 file, got ${state.files.size}`);
|
||||||
|
assert(state.files.has("A.md"), "Expected A.md to exist");
|
||||||
|
const content = state.files.get("A.md") ?? "";
|
||||||
|
assert(
|
||||||
|
content.includes("hello") && content.includes("world"),
|
||||||
|
`Expected A.md to contain both "hello" and "world", got: "${content}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const writeWriteConflictTest: TestDefinition = {
|
||||||
|
name: "Write/Write Conflict",
|
||||||
|
description:
|
||||||
|
"Two clients simultaneously create the same file with different content. " +
|
||||||
|
"The system should resolve the conflict and both clients should converge.",
|
||||||
|
clients: 2,
|
||||||
|
steps: [
|
||||||
|
{ type: "disable-sync", client: 0 },
|
||||||
|
{ type: "disable-sync", client: 1 },
|
||||||
|
{ type: "create", client: 0, path: "A.md", content: "hello" },
|
||||||
|
{ type: "create", client: 1, path: "A.md", content: "world" },
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
{ type: "assert-consistent", verify: verifyMergedContent }
|
||||||
|
]
|
||||||
|
};
|
||||||
5
frontend/deterministic-tests/src/utils/assert.ts
Normal file
5
frontend/deterministic-tests/src/utils/assert.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export function assert(value: boolean, message: string): asserts value {
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
frontend/deterministic-tests/src/utils/sleep.ts
Normal file
3
frontend/deterministic-tests/src/utils/sleep.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export async function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
12
frontend/deterministic-tests/tsconfig.json
Normal file
12
frontend/deterministic-tests/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"strict": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["DOM", "ES2024"],
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"exclude": ["./dist"]
|
||||||
|
}
|
||||||
30
frontend/deterministic-tests/webpack.config.js
Normal file
30
frontend/deterministic-tests/webpack.config.js
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
const path = require("path");
|
||||||
|
const webpack = require("webpack");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: "./src/cli.ts",
|
||||||
|
target: "node",
|
||||||
|
mode: "production",
|
||||||
|
optimization: {
|
||||||
|
minimize: false
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.ts$/,
|
||||||
|
use: "ts-loader"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: [".ts", ".js"]
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
globalObject: "this",
|
||||||
|
filename: "cli.js",
|
||||||
|
path: path.resolve(__dirname, "dist")
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
@ -8,7 +8,8 @@ export default [
|
||||||
"sync-client/src/services/types.ts",
|
"sync-client/src/services/types.ts",
|
||||||
"**/dist/",
|
"**/dist/",
|
||||||
"**/*.mjs",
|
"**/*.mjs",
|
||||||
"**/*.js"
|
"**/*.js",
|
||||||
|
"history-ui/**"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
...tseslint.config({
|
...tseslint.config({
|
||||||
|
|
@ -17,6 +18,7 @@ export default [
|
||||||
},
|
},
|
||||||
extends: [eslint.configs.recommended, tseslint.configs.all],
|
extends: [eslint.configs.recommended, tseslint.configs.all],
|
||||||
rules: {
|
rules: {
|
||||||
|
"no-console": "error",
|
||||||
"no-unused-vars": "off",
|
"no-unused-vars": "off",
|
||||||
"@typescript-eslint/restrict-template-expressions": "off",
|
"@typescript-eslint/restrict-template-expressions": "off",
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
|
|
|
||||||
13
frontend/history-ui/index.html
Normal file
13
frontend/history-ui/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>VaultLink2</title>
|
||||||
|
<link rel="icon" href="data:," />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
frontend/history-ui/package.json
Normal file
16
frontend/history-ui/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "history-ui",
|
||||||
|
"version": "0.14.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"test": "echo 'no tests yet'"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
71
frontend/history-ui/src/App.svelte
Normal file
71
frontend/history-ui/src/App.svelte
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { auth, nav, toasts } from "./lib/stores.svelte";
|
||||||
|
import Login from "./components/Login.svelte";
|
||||||
|
import Dashboard from "./components/Dashboard.svelte";
|
||||||
|
import ToastContainer from "./components/ToastContainer.svelte";
|
||||||
|
import { ApiClient } from "./lib/api";
|
||||||
|
|
||||||
|
let restoring = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const saved = auth.tryRestore();
|
||||||
|
if (saved) {
|
||||||
|
const client = new ApiClient(saved.vaultId, saved.token);
|
||||||
|
client
|
||||||
|
.ping()
|
||||||
|
.then((ping) => {
|
||||||
|
if (ping.isAuthenticated) {
|
||||||
|
auth.login(
|
||||||
|
saved.vaultId,
|
||||||
|
saved.token,
|
||||||
|
ping.serverVersion
|
||||||
|
);
|
||||||
|
}
|
||||||
|
restoring = false;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
restoring = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
restoring = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if restoring}
|
||||||
|
<div class="loading-screen">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
{:else if !auth.isAuthenticated}
|
||||||
|
<Login />
|
||||||
|
{:else}
|
||||||
|
<Dashboard
|
||||||
|
selectedDocumentId={nav.current.kind === "document" ? nav.current.documentId : undefined}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ToastContainer />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading-screen {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--bg-tertiary);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
101
frontend/history-ui/src/app.css
Normal file
101
frontend/history-ui/src/app.css
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--bg-secondary: #161b22;
|
||||||
|
--bg-tertiary: #21262d;
|
||||||
|
--bg-hover: #30363d;
|
||||||
|
--border: #30363d;
|
||||||
|
--border-light: #21262d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-muted: #8b949e;
|
||||||
|
--text-subtle: #6e7681;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--accent-hover: #79c0ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--green-bg: rgba(63, 185, 80, 0.15);
|
||||||
|
--red: #f85149;
|
||||||
|
--red-bg: rgba(248, 81, 73, 0.15);
|
||||||
|
--orange: #d29922;
|
||||||
|
--orange-bg: rgba(210, 153, 34, 0.15);
|
||||||
|
--purple: #bc8cff;
|
||||||
|
--purple-bg: rgba(188, 140, 255, 0.15);
|
||||||
|
--blue: #58a6ff;
|
||||||
|
--blue-bg: rgba(88, 166, 255, 0.15);
|
||||||
|
--mono: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
|
||||||
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Noto Sans, Helvetica, Arial, sans-serif;
|
||||||
|
--radius: 6px;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #app {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
346
frontend/history-ui/src/components/ActivityFeed.svelte
Normal file
346
frontend/history-ui/src/components/ActivityFeed.svelte
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { VersionEvent } from "../lib/types";
|
||||||
|
import {
|
||||||
|
absoluteTime,
|
||||||
|
formatBytes
|
||||||
|
} from "../lib/stores.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
versions: VersionEvent[];
|
||||||
|
loading: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
onSelectDocument: (documentId: string) => void;
|
||||||
|
onTimeTravel: (vaultUpdateId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
versions,
|
||||||
|
loading,
|
||||||
|
hasMore,
|
||||||
|
onLoadMore,
|
||||||
|
onSelectDocument,
|
||||||
|
onTimeTravel
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function timeOfDay(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by day
|
||||||
|
let grouped = $derived.by(() => {
|
||||||
|
const groups: { date: string; items: VersionEvent[] }[] = [];
|
||||||
|
const sortedDesc = [...versions].sort(
|
||||||
|
(a, b) => b.vaultUpdateId - a.vaultUpdateId
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const v of sortedDesc) {
|
||||||
|
const date = new Date(v.updatedDate).toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{ month: "long", day: "numeric", year: "numeric" }
|
||||||
|
);
|
||||||
|
const last = groups.at(-1);
|
||||||
|
if (last && last.date === date) {
|
||||||
|
last.items.push(v);
|
||||||
|
} else {
|
||||||
|
groups.push({ date, items: [v] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionColors: Record<string, string> = {
|
||||||
|
created: "var(--green)",
|
||||||
|
updated: "var(--blue)",
|
||||||
|
renamed: "var(--orange)",
|
||||||
|
deleted: "var(--red)",
|
||||||
|
restored: "var(--purple)"
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionBgColors: Record<string, string> = {
|
||||||
|
created: "var(--green-bg)",
|
||||||
|
updated: "var(--blue-bg)",
|
||||||
|
renamed: "var(--orange-bg)",
|
||||||
|
deleted: "var(--red-bg)",
|
||||||
|
restored: "var(--purple-bg)"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="feed">
|
||||||
|
{#if loading && versions.length === 0}
|
||||||
|
<div class="feed-loading">Loading activity...</div>
|
||||||
|
{:else if versions.length === 0}
|
||||||
|
<div class="feed-empty">
|
||||||
|
No activity yet. Documents will appear here as sync clients
|
||||||
|
make changes.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each grouped as group}
|
||||||
|
<div class="day-group">
|
||||||
|
<div class="day-header">{group.date}</div>
|
||||||
|
<div class="items-list">
|
||||||
|
{#each group.items as event}
|
||||||
|
<div class="feed-item">
|
||||||
|
<button
|
||||||
|
class="feed-item-main"
|
||||||
|
onclick={() =>
|
||||||
|
onSelectDocument(event.documentId)}
|
||||||
|
>
|
||||||
|
<div class="feed-timeline">
|
||||||
|
<div
|
||||||
|
class="timeline-dot"
|
||||||
|
style="background: {actionColors[
|
||||||
|
event.action
|
||||||
|
]}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="feed-content">
|
||||||
|
<div class="feed-header">
|
||||||
|
<span
|
||||||
|
class="action-pill"
|
||||||
|
style="color: {actionColors[
|
||||||
|
event.action
|
||||||
|
]}; background: {actionBgColors[
|
||||||
|
event.action
|
||||||
|
]}"
|
||||||
|
>
|
||||||
|
{event.action}
|
||||||
|
</span>
|
||||||
|
<span class="feed-path">
|
||||||
|
{#if event.action === "renamed" && event.previousPath}
|
||||||
|
<span class="prev-path"
|
||||||
|
>{event.previousPath}</span
|
||||||
|
>
|
||||||
|
<span class="arrow"
|
||||||
|
>→</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class:deleted={event.action ===
|
||||||
|
"deleted"}
|
||||||
|
>
|
||||||
|
{event.relativePath}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="feed-meta">
|
||||||
|
<span class="feed-user"
|
||||||
|
>{event.userId}</span
|
||||||
|
>
|
||||||
|
<span class="feed-dot"
|
||||||
|
>·</span
|
||||||
|
>
|
||||||
|
<span class="feed-size"
|
||||||
|
>{formatBytes(
|
||||||
|
event.contentSize
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="feed-time-btn"
|
||||||
|
title="Time travel to {absoluteTime(event.updatedDate)}"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTimeTravel(event.vaultUpdateId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timeOfDay(event.updatedDate)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if hasMore}
|
||||||
|
<div class="load-more">
|
||||||
|
<button class="load-more-btn" onclick={onLoadMore}>
|
||||||
|
Load older activity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.feed {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-loading,
|
||||||
|
.feed-empty {
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-group {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item-main {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 0 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-list {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-list::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 21px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 6px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-pill {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-path {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prev-path {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
color: var(--text-subtle);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-dot {
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-time-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-left: 1px solid transparent;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-time-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-left-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
167
frontend/history-ui/src/components/ConfirmDialog.svelte
Normal file
167
frontend/history-ui/src/components/ConfirmDialog.svelte
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel,
|
||||||
|
destructive = false,
|
||||||
|
loading = false,
|
||||||
|
onConfirm,
|
||||||
|
onCancel
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onCancel();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={handleKeydown} />
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div class="backdrop" onclick={onCancel} role="presentation">
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
|
<div
|
||||||
|
class="dialog"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
<h3 class="dialog-title">{title}</h3>
|
||||||
|
<p class="dialog-message">{message}</p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn-cancel" onclick={onCancel} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-confirm"
|
||||||
|
class:destructive
|
||||||
|
onclick={onConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<span class="btn-spinner"></span>
|
||||||
|
{/if}
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: fade-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 480px;
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
animation: scale-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-message {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: background 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm.destructive {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm.destructive:hover:not(:disabled) {
|
||||||
|
background: #f97583;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:disabled,
|
||||||
|
.btn-cancel:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scale-in {
|
||||||
|
from { transform: scale(0.95); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
507
frontend/history-ui/src/components/Dashboard.svelte
Normal file
507
frontend/history-ui/src/components/Dashboard.svelte
Normal file
|
|
@ -0,0 +1,507 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
auth,
|
||||||
|
nav,
|
||||||
|
toasts,
|
||||||
|
buildTree,
|
||||||
|
enrichVersions,
|
||||||
|
relativeTime,
|
||||||
|
formatBytes,
|
||||||
|
type View
|
||||||
|
} from "../lib/stores.svelte";
|
||||||
|
import type {
|
||||||
|
DocumentVersionWithoutContent,
|
||||||
|
VaultHistoryResponse,
|
||||||
|
VersionEvent,
|
||||||
|
TreeNode
|
||||||
|
} from "../lib/types";
|
||||||
|
import FileTree from "./FileTree.svelte";
|
||||||
|
import ActivityFeed from "./ActivityFeed.svelte";
|
||||||
|
import DocumentDetail from "./DocumentDetail.svelte";
|
||||||
|
import TimeSlider from "./TimeSlider.svelte";
|
||||||
|
import Header from "./Header.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedDocumentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { selectedDocumentId }: Props = $props();
|
||||||
|
|
||||||
|
// Data
|
||||||
|
let latestDocuments = $state<DocumentVersionWithoutContent[]>([]);
|
||||||
|
let historyVersions = $state<DocumentVersionWithoutContent[]>([]);
|
||||||
|
let historyHasMore = $state(false);
|
||||||
|
let loadingDocs = $state(true);
|
||||||
|
let loadingHistory = $state(true);
|
||||||
|
let showDeleted = $state(false);
|
||||||
|
let searchQuery = $state("");
|
||||||
|
let activeTab = $state<"activity" | "files">("activity");
|
||||||
|
|
||||||
|
// Time travel
|
||||||
|
let maxUpdateId = $state(0);
|
||||||
|
let minUpdateId = $state(0);
|
||||||
|
let timeSliderValue = $state<number | null>(null);
|
||||||
|
|
||||||
|
// Derived
|
||||||
|
let tree = $derived(buildTree(latestDocuments, showDeleted));
|
||||||
|
let enrichedHistory = $derived(enrichVersions(historyVersions));
|
||||||
|
let stats = $derived({
|
||||||
|
totalDocs: latestDocuments.filter((d) => !d.isDeleted).length,
|
||||||
|
deletedDocs: latestDocuments.filter((d) => d.isDeleted).length,
|
||||||
|
totalSize: latestDocuments
|
||||||
|
.filter((d) => !d.isDeleted)
|
||||||
|
.reduce((sum, d) => sum + d.contentSize, 0),
|
||||||
|
users: [...new Set(latestDocuments.map((d) => d.userId))]
|
||||||
|
});
|
||||||
|
|
||||||
|
let filteredTree = $derived.by(() => {
|
||||||
|
if (!searchQuery) return tree;
|
||||||
|
return filterTree(tree, searchQuery.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
function filterTree(node: TreeNode, query: string): TreeNode {
|
||||||
|
if (!node.isFolder) {
|
||||||
|
return node.name.toLowerCase().includes(query) ? node : { ...node, children: [] };
|
||||||
|
}
|
||||||
|
const filteredChildren = node.children
|
||||||
|
.map((c) => filterTree(c, query))
|
||||||
|
.filter((c) => c.isFolder ? c.children.length > 0 : true)
|
||||||
|
.filter((c) => !c.isFolder || c.children.length > 0);
|
||||||
|
return { ...node, children: filteredChildren };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time travel: compute vault state at a given updateId
|
||||||
|
let timeFilteredDocs = $derived.by(() => {
|
||||||
|
if (timeSliderValue === null || timeSliderValue >= maxUpdateId) {
|
||||||
|
return latestDocuments;
|
||||||
|
}
|
||||||
|
// From all history, find the latest version per documentId at or before timeSliderValue
|
||||||
|
const byDoc = new Map<string, DocumentVersionWithoutContent>();
|
||||||
|
for (const v of historyVersions) {
|
||||||
|
if (v.vaultUpdateId <= timeSliderValue) {
|
||||||
|
const existing = byDoc.get(v.documentId);
|
||||||
|
if (
|
||||||
|
!existing ||
|
||||||
|
v.vaultUpdateId > existing.vaultUpdateId
|
||||||
|
) {
|
||||||
|
byDoc.set(v.documentId, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byDoc.values()];
|
||||||
|
});
|
||||||
|
|
||||||
|
let timeFilteredTree = $derived(
|
||||||
|
buildTree(
|
||||||
|
timeSliderValue !== null && timeSliderValue < maxUpdateId
|
||||||
|
? timeFilteredDocs
|
||||||
|
: latestDocuments,
|
||||||
|
showDeleted
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let displayTree = $derived(
|
||||||
|
searchQuery ? filteredTree : timeFilteredTree
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
async function loadData() {
|
||||||
|
const api = auth.api;
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
loadingDocs = true;
|
||||||
|
loadingHistory = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.fetchLatestDocuments();
|
||||||
|
latestDocuments = response.latestDocuments;
|
||||||
|
maxUpdateId = response.lastUpdateId;
|
||||||
|
} catch (e) {
|
||||||
|
toasts.add("Failed to load documents", "error");
|
||||||
|
} finally {
|
||||||
|
loadingDocs = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.fetchVaultHistory(500);
|
||||||
|
historyVersions = response.versions;
|
||||||
|
historyHasMore = response.hasMore;
|
||||||
|
if (historyVersions.length > 0) {
|
||||||
|
minUpdateId = Math.min(
|
||||||
|
...historyVersions.map((v) => v.vaultUpdateId)
|
||||||
|
);
|
||||||
|
maxUpdateId = Math.max(
|
||||||
|
maxUpdateId,
|
||||||
|
Math.max(
|
||||||
|
...historyVersions.map((v) => v.vaultUpdateId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toasts.add("Failed to load history", "error");
|
||||||
|
} finally {
|
||||||
|
loadingHistory = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreHistory() {
|
||||||
|
const api = auth.api;
|
||||||
|
if (!api || !historyHasMore) return;
|
||||||
|
|
||||||
|
const oldest = Math.min(
|
||||||
|
...historyVersions.map((v) => v.vaultUpdateId)
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const response = await api.fetchVaultHistory(500, oldest);
|
||||||
|
historyVersions = [...historyVersions, ...response.versions];
|
||||||
|
historyHasMore = response.hasMore;
|
||||||
|
minUpdateId = Math.min(
|
||||||
|
minUpdateId,
|
||||||
|
...response.versions.map((v) => v.vaultUpdateId)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toasts.add("Failed to load more history", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDocument(documentId: string) {
|
||||||
|
nav.goto({ kind: "document", documentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dashboard">
|
||||||
|
<Header
|
||||||
|
vaultId={auth.vaultId}
|
||||||
|
serverVersion={auth.serverVersion}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="main-layout">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
{#if !loadingDocs}
|
||||||
|
<div class="sidebar-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{stats.totalDocs}</span>
|
||||||
|
<span class="stat-label">files</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value"
|
||||||
|
>{formatBytes(stats.totalSize)}</span
|
||||||
|
>
|
||||||
|
<span class="stat-label">total</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{stats.users.length}</span>
|
||||||
|
<span class="stat-label"
|
||||||
|
>user{stats.users.length !== 1 ? "s" : ""}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="sidebar-search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter files..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-controls">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={showDeleted}
|
||||||
|
/>
|
||||||
|
Show deleted
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-tree">
|
||||||
|
{#if loadingDocs}
|
||||||
|
<div class="loading-placeholder">Loading...</div>
|
||||||
|
{:else}
|
||||||
|
<FileTree
|
||||||
|
node={displayTree}
|
||||||
|
selectedId={selectedDocumentId ?? null}
|
||||||
|
onSelect={selectDocument}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="content">
|
||||||
|
{#if maxUpdateId > 0}
|
||||||
|
<div class="time-slider-container">
|
||||||
|
<TimeSlider
|
||||||
|
min={minUpdateId}
|
||||||
|
max={maxUpdateId}
|
||||||
|
value={timeSliderValue}
|
||||||
|
versions={historyVersions}
|
||||||
|
onchange={(v) => {
|
||||||
|
timeSliderValue = v;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedDocumentId}
|
||||||
|
<DocumentDetail
|
||||||
|
documentId={selectedDocumentId}
|
||||||
|
onClose={() => nav.goHome()}
|
||||||
|
onRestore={handleRefresh}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:active={activeTab === "activity"}
|
||||||
|
onclick={() => (activeTab = "activity")}
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:active={activeTab === "files"}
|
||||||
|
onclick={() => (activeTab = "files")}
|
||||||
|
>
|
||||||
|
Files
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeTab === "activity"}
|
||||||
|
<ActivityFeed
|
||||||
|
versions={enrichedHistory}
|
||||||
|
loading={loadingHistory}
|
||||||
|
hasMore={historyHasMore}
|
||||||
|
onLoadMore={loadMoreHistory}
|
||||||
|
onSelectDocument={selectDocument}
|
||||||
|
onTimeTravel={(id) => {
|
||||||
|
timeSliderValue = id >= maxUpdateId ? null : id;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="file-list">
|
||||||
|
{#each latestDocuments
|
||||||
|
.filter((d) => showDeleted || !d.isDeleted)
|
||||||
|
.sort((a, b) => b.vaultUpdateId - a.vaultUpdateId) as doc}
|
||||||
|
<button
|
||||||
|
class="file-row"
|
||||||
|
class:deleted={doc.isDeleted}
|
||||||
|
onclick={() =>
|
||||||
|
selectDocument(doc.documentId)}
|
||||||
|
>
|
||||||
|
<span class="file-icon"
|
||||||
|
>{doc.isDeleted
|
||||||
|
? "🗑"
|
||||||
|
: "📄"}</span
|
||||||
|
>
|
||||||
|
<span class="file-path"
|
||||||
|
>{doc.relativePath}</span
|
||||||
|
>
|
||||||
|
<span class="file-meta">
|
||||||
|
{formatBytes(doc.contentSize)}
|
||||||
|
·
|
||||||
|
{doc.userId}
|
||||||
|
·
|
||||||
|
{relativeTime(doc.updatedDate)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search input {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-controls {
|
||||||
|
padding: 4px 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-placeholder {
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slider-container {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 16px;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row.deleted {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row.deleted .file-path {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-path {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
288
frontend/history-ui/src/components/DiffView.svelte
Normal file
288
frontend/history-ui/src/components/DiffView.svelte
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
oldContent: string;
|
||||||
|
newContent: string;
|
||||||
|
oldLabel: string;
|
||||||
|
newLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { oldContent, newContent, oldLabel, newLabel }: Props = $props();
|
||||||
|
|
||||||
|
interface DiffLine {
|
||||||
|
type: "add" | "remove" | "context";
|
||||||
|
content: string;
|
||||||
|
oldLineNo: number | null;
|
||||||
|
newLineNo: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let diffLines = $derived.by((): DiffLine[] => {
|
||||||
|
const oldLines = oldContent.split("\n");
|
||||||
|
const newLines = newContent.split("\n");
|
||||||
|
|
||||||
|
// Simple line-by-line diff using LCS
|
||||||
|
const lines: DiffLine[] = [];
|
||||||
|
const lcs = computeLCS(oldLines, newLines);
|
||||||
|
|
||||||
|
let oi = 0;
|
||||||
|
let ni = 0;
|
||||||
|
let oldLineNo = 1;
|
||||||
|
let newLineNo = 1;
|
||||||
|
|
||||||
|
for (const match of lcs) {
|
||||||
|
// Remove lines before match
|
||||||
|
while (oi < match.oldIndex) {
|
||||||
|
lines.push({
|
||||||
|
type: "remove",
|
||||||
|
content: oldLines[oi],
|
||||||
|
oldLineNo: oldLineNo++,
|
||||||
|
newLineNo: null
|
||||||
|
});
|
||||||
|
oi++;
|
||||||
|
}
|
||||||
|
// Add lines before match
|
||||||
|
while (ni < match.newIndex) {
|
||||||
|
lines.push({
|
||||||
|
type: "add",
|
||||||
|
content: newLines[ni],
|
||||||
|
oldLineNo: null,
|
||||||
|
newLineNo: newLineNo++
|
||||||
|
});
|
||||||
|
ni++;
|
||||||
|
}
|
||||||
|
// Context line
|
||||||
|
lines.push({
|
||||||
|
type: "context",
|
||||||
|
content: oldLines[oi],
|
||||||
|
oldLineNo: oldLineNo++,
|
||||||
|
newLineNo: newLineNo++
|
||||||
|
});
|
||||||
|
oi++;
|
||||||
|
ni++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining removes
|
||||||
|
while (oi < oldLines.length) {
|
||||||
|
lines.push({
|
||||||
|
type: "remove",
|
||||||
|
content: oldLines[oi],
|
||||||
|
oldLineNo: oldLineNo++,
|
||||||
|
newLineNo: null
|
||||||
|
});
|
||||||
|
oi++;
|
||||||
|
}
|
||||||
|
// Remaining adds
|
||||||
|
while (ni < newLines.length) {
|
||||||
|
lines.push({
|
||||||
|
type: "add",
|
||||||
|
content: newLines[ni],
|
||||||
|
oldLineNo: null,
|
||||||
|
newLineNo: newLineNo++
|
||||||
|
});
|
||||||
|
ni++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
});
|
||||||
|
|
||||||
|
let stats = $derived({
|
||||||
|
added: diffLines.filter((l) => l.type === "add").length,
|
||||||
|
removed: diffLines.filter((l) => l.type === "remove").length
|
||||||
|
});
|
||||||
|
|
||||||
|
interface LCSMatch {
|
||||||
|
oldIndex: number;
|
||||||
|
newIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeLCS(a: string[], b: string[]): LCSMatch[] {
|
||||||
|
const m = a.length;
|
||||||
|
const n = b.length;
|
||||||
|
|
||||||
|
// For large files, use a simpler approach
|
||||||
|
if (m * n > 1_000_000) {
|
||||||
|
return simpleDiff(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||||
|
new Array(n + 1).fill(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
if (a[i - 1] === b[j - 1]) {
|
||||||
|
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||||
|
} else {
|
||||||
|
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backtrack
|
||||||
|
const matches: LCSMatch[] = [];
|
||||||
|
let i = m;
|
||||||
|
let j = n;
|
||||||
|
while (i > 0 && j > 0) {
|
||||||
|
if (a[i - 1] === b[j - 1]) {
|
||||||
|
matches.unshift({ oldIndex: i - 1, newIndex: j - 1 });
|
||||||
|
i--;
|
||||||
|
j--;
|
||||||
|
} else if (dp[i - 1][j] > dp[i][j - 1]) {
|
||||||
|
i--;
|
||||||
|
} else {
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simpleDiff(a: string[], b: string[]): LCSMatch[] {
|
||||||
|
// Hash-based matching for large files
|
||||||
|
const bMap = new Map<string, number[]>();
|
||||||
|
for (let j = 0; j < b.length; j++) {
|
||||||
|
const arr = bMap.get(b[j]);
|
||||||
|
if (arr) arr.push(j);
|
||||||
|
else bMap.set(b[j], [j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches: LCSMatch[] = [];
|
||||||
|
let lastJ = -1;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
const candidates = bMap.get(a[i]);
|
||||||
|
if (!candidates) continue;
|
||||||
|
for (const j of candidates) {
|
||||||
|
if (j > lastJ) {
|
||||||
|
matches.push({ oldIndex: i, newIndex: j });
|
||||||
|
lastJ = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="diff-view">
|
||||||
|
<div class="diff-header">
|
||||||
|
<span class="diff-label">{oldLabel}</span>
|
||||||
|
<span class="diff-arrow">→</span>
|
||||||
|
<span class="diff-label">{newLabel}</span>
|
||||||
|
<span class="diff-stats">
|
||||||
|
<span class="diff-added">+{stats.added}</span>
|
||||||
|
<span class="diff-removed">-{stats.removed}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="diff-content">
|
||||||
|
{#each diffLines as line}
|
||||||
|
<div class="diff-line {line.type}">
|
||||||
|
<span class="line-no old-no">
|
||||||
|
{line.oldLineNo ?? ""}
|
||||||
|
</span>
|
||||||
|
<span class="line-no new-no">
|
||||||
|
{line.newLineNo ?? ""}
|
||||||
|
</span>
|
||||||
|
<span class="line-marker">
|
||||||
|
{#if line.type === "add"}+{:else if line.type === "remove"}-{:else} {/if}
|
||||||
|
</span>
|
||||||
|
<span class="line-content">{line.content}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.diff-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-arrow {
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-stats {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-added {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-removed {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line {
|
||||||
|
display: flex;
|
||||||
|
white-space: pre;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.add {
|
||||||
|
background: var(--green-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.remove {
|
||||||
|
background: var(--red-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-no {
|
||||||
|
display: inline-block;
|
||||||
|
width: 48px;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 8px;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-marker {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.add .line-marker {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.remove .line-marker {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-content {
|
||||||
|
flex: 1;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
712
frontend/history-ui/src/components/DocumentDetail.svelte
Normal file
712
frontend/history-ui/src/components/DocumentDetail.svelte
Normal file
|
|
@ -0,0 +1,712 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
auth,
|
||||||
|
toasts,
|
||||||
|
relativeTime,
|
||||||
|
absoluteTime,
|
||||||
|
formatBytes,
|
||||||
|
inferAction,
|
||||||
|
isTextFile,
|
||||||
|
isImageFile,
|
||||||
|
fileExtension
|
||||||
|
} from "../lib/stores.svelte";
|
||||||
|
import type {
|
||||||
|
DocumentVersionWithoutContent,
|
||||||
|
DocumentVersion,
|
||||||
|
ActionType
|
||||||
|
} from "../lib/types";
|
||||||
|
import DiffView from "./DiffView.svelte";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
documentId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onRestore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { documentId, onClose, onRestore }: Props = $props();
|
||||||
|
|
||||||
|
let versions = $state<DocumentVersionWithoutContent[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let selectedVersion = $state<DocumentVersionWithoutContent | null>(null);
|
||||||
|
let loadedContent = $state<string | null>(null);
|
||||||
|
let loadedContentBytes = $state<ArrayBuffer | null>(null);
|
||||||
|
let loadingContent = $state(false);
|
||||||
|
let activeTab = $state<"preview" | "diff">("preview");
|
||||||
|
|
||||||
|
// Diff state
|
||||||
|
let diffOldContent = $state<string | null>(null);
|
||||||
|
let diffNewContent = $state<string | null>(null);
|
||||||
|
let diffOldLabel = $state("");
|
||||||
|
let diffNewLabel = $state("");
|
||||||
|
|
||||||
|
// Restore state
|
||||||
|
let showRestoreDialog = $state(false);
|
||||||
|
let restoreTarget = $state<DocumentVersionWithoutContent | null>(null);
|
||||||
|
let restoring = $state(false);
|
||||||
|
|
||||||
|
let latest = $derived(versions.at(-1) ?? null);
|
||||||
|
let isDeleted = $derived(latest?.isDeleted ?? false);
|
||||||
|
let currentPath = $derived(latest?.relativePath ?? "");
|
||||||
|
|
||||||
|
// Derive action types
|
||||||
|
let versionEvents = $derived(
|
||||||
|
versions.map((v, i) => ({
|
||||||
|
version: v,
|
||||||
|
action: inferAction(v, i > 0 ? versions[i - 1] : undefined) as ActionType,
|
||||||
|
previousPath: i > 0 && versions[i - 1].relativePath !== v.relativePath
|
||||||
|
? versions[i - 1].relativePath
|
||||||
|
: undefined
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadVersions() {
|
||||||
|
const api = auth.api;
|
||||||
|
if (!api) return;
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
versions = await api.fetchDocumentVersions(documentId);
|
||||||
|
// Auto-select latest
|
||||||
|
if (versions.length > 0) {
|
||||||
|
await selectVersion(versions.at(-1)!);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toasts.add("Failed to load document versions", "error");
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectVersion(v: DocumentVersionWithoutContent) {
|
||||||
|
selectedVersion = v;
|
||||||
|
activeTab = "preview";
|
||||||
|
diffOldContent = null;
|
||||||
|
diffNewContent = null;
|
||||||
|
loadingContent = true;
|
||||||
|
loadedContent = null;
|
||||||
|
loadedContentBytes = null;
|
||||||
|
|
||||||
|
const api = auth.api;
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isTextFile(v.relativePath) || fileExtension(v.relativePath) === "") {
|
||||||
|
const fullVersion = await api.fetchDocumentVersion(
|
||||||
|
documentId,
|
||||||
|
v.vaultUpdateId
|
||||||
|
);
|
||||||
|
const bytes = Uint8Array.from(atob(fullVersion.contentBase64), c => c.charCodeAt(0));
|
||||||
|
const decoder = new TextDecoder("utf-8", { fatal: false });
|
||||||
|
loadedContent = decoder.decode(bytes);
|
||||||
|
loadedContentBytes = bytes.buffer;
|
||||||
|
} else if (isImageFile(v.relativePath)) {
|
||||||
|
loadedContentBytes = await api.fetchDocumentVersionContent(
|
||||||
|
documentId,
|
||||||
|
v.vaultUpdateId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
loadedContentBytes = await api.fetchDocumentVersionContent(
|
||||||
|
documentId,
|
||||||
|
v.vaultUpdateId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toasts.add("Failed to load content", "error");
|
||||||
|
} finally {
|
||||||
|
loadingContent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showDiff(v: DocumentVersionWithoutContent, idx: number) {
|
||||||
|
const api = auth.api;
|
||||||
|
if (!api || idx === 0) return;
|
||||||
|
|
||||||
|
activeTab = "diff";
|
||||||
|
loadingContent = true;
|
||||||
|
|
||||||
|
const prev = versions[idx - 1];
|
||||||
|
try {
|
||||||
|
const [oldVer, newVer] = await Promise.all([
|
||||||
|
api.fetchDocumentVersion(documentId, prev.vaultUpdateId),
|
||||||
|
api.fetchDocumentVersion(documentId, v.vaultUpdateId)
|
||||||
|
]);
|
||||||
|
const decode = (b64: string) => {
|
||||||
|
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||||||
|
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||||
|
};
|
||||||
|
diffOldContent = decode(oldVer.contentBase64);
|
||||||
|
diffNewContent = decode(newVer.contentBase64);
|
||||||
|
diffOldLabel = `v${prev.vaultUpdateId}`;
|
||||||
|
diffNewLabel = `v${v.vaultUpdateId}`;
|
||||||
|
} catch {
|
||||||
|
toasts.add("Failed to load diff", "error");
|
||||||
|
} finally {
|
||||||
|
loadingContent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRestore(v: DocumentVersionWithoutContent) {
|
||||||
|
restoreTarget = v;
|
||||||
|
showRestoreDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeRestore() {
|
||||||
|
const api = auth.api;
|
||||||
|
if (!api || !restoreTarget) return;
|
||||||
|
restoring = true;
|
||||||
|
try {
|
||||||
|
await api.restoreVersion(
|
||||||
|
documentId,
|
||||||
|
restoreTarget.vaultUpdateId
|
||||||
|
);
|
||||||
|
toasts.add(
|
||||||
|
`Restored to version #${restoreTarget.vaultUpdateId}`,
|
||||||
|
"success"
|
||||||
|
);
|
||||||
|
showRestoreDialog = false;
|
||||||
|
restoreTarget = null;
|
||||||
|
onRestore();
|
||||||
|
await loadVersions();
|
||||||
|
} catch (e) {
|
||||||
|
toasts.add(`Restore failed: ${e}`, "error");
|
||||||
|
} finally {
|
||||||
|
restoring = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageUrl(buffer: ArrayBuffer, path: string): string {
|
||||||
|
const ext = fileExtension(path);
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
webp: "image/webp",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
ico: "image/x-icon",
|
||||||
|
bmp: "image/bmp"
|
||||||
|
};
|
||||||
|
const mime = mimeMap[ext] ?? "application/octet-stream";
|
||||||
|
const blob = new Blob([buffer], { type: mime });
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadVersions();
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionColors: Record<string, string> = {
|
||||||
|
created: "var(--green)",
|
||||||
|
updated: "var(--blue)",
|
||||||
|
renamed: "var(--orange)",
|
||||||
|
deleted: "var(--red)",
|
||||||
|
restored: "var(--purple)"
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionBgColors: Record<string, string> = {
|
||||||
|
created: "var(--green-bg)",
|
||||||
|
updated: "var(--blue-bg)",
|
||||||
|
renamed: "var(--orange-bg)",
|
||||||
|
deleted: "var(--red-bg)",
|
||||||
|
restored: "var(--purple-bg)"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="detail">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="detail-header">
|
||||||
|
<button class="back-btn" onclick={onClose} title="Back">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="header-info">
|
||||||
|
<div class="header-path">
|
||||||
|
<span class="path-text" class:deleted-path={isDeleted}>
|
||||||
|
{currentPath}
|
||||||
|
</span>
|
||||||
|
{#if isDeleted}
|
||||||
|
<span class="status-badge deleted-badge">Deleted</span>
|
||||||
|
{:else}
|
||||||
|
<span class="status-badge active-badge">Active</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="header-meta">
|
||||||
|
<span class="doc-id" title={documentId}>
|
||||||
|
{documentId.substring(0, 8)}...
|
||||||
|
</span>
|
||||||
|
{#if latest}
|
||||||
|
<span>·</span>
|
||||||
|
<span>{versions.length} version{versions.length !== 1 ? "s" : ""}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>Last by {latest.userId}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="detail-loading">Loading versions...</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Content area -->
|
||||||
|
<div class="detail-body">
|
||||||
|
<div class="content-panel">
|
||||||
|
{#if selectedVersion}
|
||||||
|
<div class="content-tabs">
|
||||||
|
<button
|
||||||
|
class="content-tab"
|
||||||
|
class:active={activeTab === "preview"}
|
||||||
|
onclick={() => (activeTab = "preview")}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="content-tab"
|
||||||
|
class:active={activeTab === "diff"}
|
||||||
|
onclick={() => {
|
||||||
|
if (selectedVersion) {
|
||||||
|
const idx = versions.indexOf(selectedVersion);
|
||||||
|
if (idx > 0) showDiff(selectedVersion, idx);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={versions.indexOf(selectedVersion) === 0}
|
||||||
|
>
|
||||||
|
Diff
|
||||||
|
</button>
|
||||||
|
<div class="content-tab-spacer"></div>
|
||||||
|
<span class="viewing-label">
|
||||||
|
Viewing v#{selectedVersion.vaultUpdateId}
|
||||||
|
·
|
||||||
|
{relativeTime(selectedVersion.updatedDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-view">
|
||||||
|
{#if loadingContent}
|
||||||
|
<div class="content-loading">Loading content...</div>
|
||||||
|
{:else if activeTab === "diff" && diffOldContent !== null && diffNewContent !== null}
|
||||||
|
<DiffView
|
||||||
|
oldContent={diffOldContent}
|
||||||
|
newContent={diffNewContent}
|
||||||
|
oldLabel={diffOldLabel}
|
||||||
|
newLabel={diffNewLabel}
|
||||||
|
/>
|
||||||
|
{:else if activeTab === "preview"}
|
||||||
|
{#if isTextFile(selectedVersion.relativePath) || fileExtension(selectedVersion.relativePath) === ""}
|
||||||
|
<pre class="text-content">{loadedContent ?? ""}</pre>
|
||||||
|
{:else if isImageFile(selectedVersion.relativePath) && loadedContentBytes}
|
||||||
|
<div class="image-preview">
|
||||||
|
<img
|
||||||
|
src={getImageUrl(loadedContentBytes, selectedVersion.relativePath)}
|
||||||
|
alt={selectedVersion.relativePath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="binary-placeholder">
|
||||||
|
<div class="binary-icon">📦</div>
|
||||||
|
<div class="binary-label">Binary file</div>
|
||||||
|
<div class="binary-size">
|
||||||
|
{formatBytes(selectedVersion.contentSize)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version timeline -->
|
||||||
|
<div class="version-panel">
|
||||||
|
<div class="version-panel-header">Version History</div>
|
||||||
|
<div class="version-list">
|
||||||
|
{#each [...versionEvents].reverse() as event, i}
|
||||||
|
{@const v = event.version}
|
||||||
|
{@const isSelected = selectedVersion?.vaultUpdateId === v.vaultUpdateId}
|
||||||
|
<div class="version-item" class:selected={isSelected}>
|
||||||
|
<button
|
||||||
|
class="version-main"
|
||||||
|
onclick={() => selectVersion(v)}
|
||||||
|
>
|
||||||
|
<div class="version-left">
|
||||||
|
<span class="version-id">#{v.vaultUpdateId}</span>
|
||||||
|
<span
|
||||||
|
class="version-action"
|
||||||
|
style="color: {actionColors[event.action]}; background: {actionBgColors[event.action]}"
|
||||||
|
>
|
||||||
|
{event.action}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="version-right">
|
||||||
|
<span class="version-user">{v.userId}</span>
|
||||||
|
<span
|
||||||
|
class="version-time"
|
||||||
|
title={absoluteTime(v.updatedDate)}
|
||||||
|
>
|
||||||
|
{relativeTime(v.updatedDate)}
|
||||||
|
</span>
|
||||||
|
<span class="version-size">{formatBytes(v.contentSize)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{#if event.previousPath}
|
||||||
|
<div class="version-rename">
|
||||||
|
{event.previousPath} → {v.relativePath}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="version-actions">
|
||||||
|
{#if i < versionEvents.length - 1}
|
||||||
|
<button
|
||||||
|
class="version-btn"
|
||||||
|
onclick={() => {
|
||||||
|
const realIdx = versions.indexOf(v);
|
||||||
|
showDiff(v, realIdx);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Diff
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if v !== latest}
|
||||||
|
<button
|
||||||
|
class="version-btn restore-btn"
|
||||||
|
onclick={() => confirmRestore(v)}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showRestoreDialog && restoreTarget}
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Restore Version"
|
||||||
|
message={`Restore "${currentPath}" to version #${restoreTarget.vaultUpdateId} from ${absoluteTime(restoreTarget.updatedDate)}? This creates a new version with the old content. Current content is preserved in history.`}
|
||||||
|
confirmLabel="Restore"
|
||||||
|
destructive={false}
|
||||||
|
loading={restoring}
|
||||||
|
onConfirm={executeRestore}
|
||||||
|
onCancel={() => {
|
||||||
|
showRestoreDialog = false;
|
||||||
|
restoreTarget = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-path {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-text {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-path {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-badge {
|
||||||
|
color: var(--green);
|
||||||
|
background: var(--green-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-badge {
|
||||||
|
color: var(--red);
|
||||||
|
background: var(--red-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-id {
|
||||||
|
font-family: var(--mono);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-loading {
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-tab {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-tab:hover:not(:disabled) {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-tab:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-tab-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewing-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-view {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-loading {
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content {
|
||||||
|
padding: 16px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 60vh;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binary-placeholder {
|
||||||
|
padding: 64px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binary-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binary-label {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binary-size {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version panel */
|
||||||
|
.version-panel {
|
||||||
|
width: 320px;
|
||||||
|
min-width: 320px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-panel-header {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item {
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding: 8px 12px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item.selected {
|
||||||
|
background: var(--blue-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-main {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-id {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-action {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-rename {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--orange);
|
||||||
|
font-family: var(--mono);
|
||||||
|
margin: 4px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-btn {
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
124
frontend/history-ui/src/components/FileTree.svelte
Normal file
124
frontend/history-ui/src/components/FileTree.svelte
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { TreeNode } from "../lib/types";
|
||||||
|
import FileTree from "./FileTree.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: TreeNode;
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (documentId: string) => void;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { node, selectedId, onSelect, depth = 0 }: Props = $props();
|
||||||
|
|
||||||
|
let expanded = $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
function toggle(path: string) {
|
||||||
|
expanded[path] = !expanded[path];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpanded(path: string): boolean {
|
||||||
|
return expanded[path] ?? true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if node.isFolder && depth === 0}
|
||||||
|
{#each node.children as child}
|
||||||
|
<FileTree
|
||||||
|
node={child}
|
||||||
|
{selectedId}
|
||||||
|
{onSelect}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{:else if node.isFolder}
|
||||||
|
<div class="tree-folder">
|
||||||
|
<button
|
||||||
|
class="tree-item folder"
|
||||||
|
style="padding-left: {depth * 16}px"
|
||||||
|
onclick={() => toggle(node.path)}
|
||||||
|
>
|
||||||
|
<span class="expand-icon"
|
||||||
|
>{isExpanded(node.path) ? "▾" : "▸"}</span
|
||||||
|
>
|
||||||
|
<span class="folder-icon">📁</span>
|
||||||
|
<span class="node-name">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
{#if isExpanded(node.path)}
|
||||||
|
{#each node.children as child}
|
||||||
|
<FileTree
|
||||||
|
node={child}
|
||||||
|
{selectedId}
|
||||||
|
{onSelect}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="tree-item file"
|
||||||
|
class:selected={node.document?.documentId === selectedId}
|
||||||
|
class:deleted={node.isDeleted}
|
||||||
|
style="padding-left: {depth * 16 + 8}px"
|
||||||
|
onclick={() =>
|
||||||
|
node.document && onSelect(node.document.documentId)}
|
||||||
|
>
|
||||||
|
<span class="file-icon">{node.isDeleted ? "○" : "●"}</span>
|
||||||
|
<span class="node-name">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tree-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 3px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item.selected {
|
||||||
|
background: var(--blue-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item.deleted {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item.deleted .node-name {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
width: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
126
frontend/history-ui/src/components/Header.svelte
Normal file
126
frontend/history-ui/src/components/Header.svelte
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { auth } from "../lib/stores.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
vaultId: string;
|
||||||
|
serverVersion: string;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { vaultId, serverVersion, onRefresh }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||||
|
<path d="M2 17l10 5 10-5" />
|
||||||
|
<path d="M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
<span class="header-title">VaultLink</span>
|
||||||
|
<span class="header-sep">/</span>
|
||||||
|
<span class="header-vault">{vaultId}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<span class="server-version">v{serverVersion}</span>
|
||||||
|
<button class="header-btn" onclick={onRefresh} title="Refresh">
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="header-btn"
|
||||||
|
onclick={() => auth.logout()}
|
||||||
|
title="Disconnect"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-sep {
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-vault {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-version {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
194
frontend/history-ui/src/components/Login.svelte
Normal file
194
frontend/history-ui/src/components/Login.svelte
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { auth, toasts } from "../lib/stores.svelte";
|
||||||
|
import { ApiClient } from "../lib/api";
|
||||||
|
|
||||||
|
let vaultId = $state("");
|
||||||
|
let token = $state("");
|
||||||
|
let error = $state("");
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!vaultId.trim() || !token.trim()) {
|
||||||
|
error = "Both fields are required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error = "";
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const client = new ApiClient(vaultId.trim(), token.trim());
|
||||||
|
const ping = await client.ping();
|
||||||
|
if (ping.isAuthenticated) {
|
||||||
|
auth.login(
|
||||||
|
vaultId.trim(),
|
||||||
|
token.trim(),
|
||||||
|
ping.serverVersion
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
error = "Authentication failed. Check your token.";
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error =
|
||||||
|
"Cannot reach server. Check the vault name and your network.";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="logo">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
|
<path d="M2 17l10 5 10-5"/>
|
||||||
|
<path d="M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
<h1>VaultLink</h1>
|
||||||
|
</div>
|
||||||
|
<p class="subtitle">Vault History Browser</p>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
<span>Vault name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={vaultId}
|
||||||
|
placeholder="my-vault"
|
||||||
|
autocomplete="off"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Token</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={token}
|
||||||
|
placeholder="Enter your access token"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary" disabled={loading}>
|
||||||
|
{#if loading}
|
||||||
|
<span class="btn-spinner"></span>
|
||||||
|
Connecting...
|
||||||
|
{:else}
|
||||||
|
Connect
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 40px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label span {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--red-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: background 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
191
frontend/history-ui/src/components/TimeSlider.svelte
Normal file
191
frontend/history-ui/src/components/TimeSlider.svelte
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { DocumentVersionWithoutContent } from "../lib/types";
|
||||||
|
import { relativeTime, absoluteTime } from "../lib/stores.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
value: number | null;
|
||||||
|
versions: DocumentVersionWithoutContent[];
|
||||||
|
onchange: (value: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { min, max, value, versions, onchange }: Props = $props();
|
||||||
|
|
||||||
|
let isNow = $derived(value === null || value >= max);
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const v = parseInt(target.value, 10);
|
||||||
|
if (v >= max) {
|
||||||
|
onchange(null);
|
||||||
|
} else {
|
||||||
|
onchange(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapToNow() {
|
||||||
|
onchange(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentVersion = $derived(
|
||||||
|
value !== null
|
||||||
|
? versions.find((v) => v.vaultUpdateId === value) ??
|
||||||
|
versions.reduce(
|
||||||
|
(closest, v) =>
|
||||||
|
Math.abs(v.vaultUpdateId - (value ?? max)) <
|
||||||
|
Math.abs(
|
||||||
|
closest.vaultUpdateId - (value ?? max)
|
||||||
|
)
|
||||||
|
? v
|
||||||
|
: closest,
|
||||||
|
versions[0]
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="time-slider">
|
||||||
|
<div class="slider-label">
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<span class="label-text">Time Travel</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-track">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={value ?? max}
|
||||||
|
oninput={handleInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-info">
|
||||||
|
{#if isNow}
|
||||||
|
<span class="now-badge">Now</span>
|
||||||
|
{:else if currentVersion}
|
||||||
|
<span
|
||||||
|
class="time-info"
|
||||||
|
title={absoluteTime(currentVersion.updatedDate)}
|
||||||
|
>
|
||||||
|
#{value}
|
||||||
|
·
|
||||||
|
{relativeTime(currentVersion.updatedDate)}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="time-info">#{value}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !isNow}
|
||||||
|
<button class="snap-btn" onclick={snapToNow} title="Back to now">
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.time-slider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track input[type="range"]::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-info {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--green);
|
||||||
|
background: var(--green-bg);
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snap-btn {
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snap-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
80
frontend/history-ui/src/components/ToastContainer.svelte
Normal file
80
frontend/history-ui/src/components/ToastContainer.svelte
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { toasts } from "../lib/stores.svelte";
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
success: "var(--green)",
|
||||||
|
error: "var(--red)",
|
||||||
|
info: "var(--accent)"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if toasts.items.length > 0}
|
||||||
|
<div class="toast-container">
|
||||||
|
{#each toasts.items as toast (toast.id)}
|
||||||
|
<div
|
||||||
|
class="toast"
|
||||||
|
style="border-left-color: {typeColors[toast.type]}"
|
||||||
|
>
|
||||||
|
<span class="toast-message">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
class="toast-dismiss"
|
||||||
|
onclick={() => toasts.dismiss(toast.id)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left-width: 3px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: slide-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-dismiss {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-dismiss:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
109
frontend/history-ui/src/lib/api.ts
Normal file
109
frontend/history-ui/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import type {
|
||||||
|
DocumentVersion,
|
||||||
|
DocumentVersionWithoutContent,
|
||||||
|
FetchLatestDocumentsResponse,
|
||||||
|
PingResponse,
|
||||||
|
VaultHistoryResponse
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export class ApiClient {
|
||||||
|
constructor(
|
||||||
|
private vaultId: string,
|
||||||
|
private token: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get baseUrl(): string {
|
||||||
|
return `/vaults/${encodeURIComponent(this.vaultId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private headers(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
"device-id": "history-ui"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson<T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: { ...this.headers(), ...init?.headers }
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${body}`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async ping(): Promise<PingResponse> {
|
||||||
|
return this.fetchJson(`${this.baseUrl}/ping`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchLatestDocuments(): Promise<FetchLatestDocumentsResponse> {
|
||||||
|
return this.fetchJson(`${this.baseUrl}/documents`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchDocumentVersions(
|
||||||
|
documentId: string
|
||||||
|
): Promise<DocumentVersionWithoutContent[]> {
|
||||||
|
return this.fetchJson(
|
||||||
|
`${this.baseUrl}/documents/${documentId}/versions`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchDocumentVersion(
|
||||||
|
documentId: string,
|
||||||
|
vaultUpdateId: number
|
||||||
|
): Promise<DocumentVersion> {
|
||||||
|
return this.fetchJson(
|
||||||
|
`${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchDocumentVersionContent(
|
||||||
|
documentId: string,
|
||||||
|
vaultUpdateId: number
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}/content`,
|
||||||
|
{ headers: this.headers() }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchVaultHistory(
|
||||||
|
limit?: number,
|
||||||
|
beforeUpdateId?: number
|
||||||
|
): Promise<VaultHistoryResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (limit !== undefined) params.set("limit", String(limit));
|
||||||
|
if (beforeUpdateId !== undefined)
|
||||||
|
params.set("before_update_id", String(beforeUpdateId));
|
||||||
|
const qs = params.toString();
|
||||||
|
return this.fetchJson(
|
||||||
|
`${this.baseUrl}/history${qs ? `?${qs}` : ""}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreVersion(
|
||||||
|
documentId: string,
|
||||||
|
vaultUpdateId: number
|
||||||
|
): Promise<DocumentVersionWithoutContent> {
|
||||||
|
return this.fetchJson(
|
||||||
|
`${this.baseUrl}/documents/${documentId}/restore`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ vaultUpdateId })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
291
frontend/history-ui/src/lib/stores.svelte.ts
Normal file
291
frontend/history-ui/src/lib/stores.svelte.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
import { ApiClient } from "./api";
|
||||||
|
import type {
|
||||||
|
DocumentVersionWithoutContent,
|
||||||
|
VersionEvent,
|
||||||
|
ActionType,
|
||||||
|
TreeNode
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
class AuthStore {
|
||||||
|
vaultId = $state("");
|
||||||
|
token = $state("");
|
||||||
|
isAuthenticated = $state(false);
|
||||||
|
serverVersion = $state("");
|
||||||
|
api = $state<ApiClient | null>(null);
|
||||||
|
|
||||||
|
login(vaultId: string, token: string, serverVersion: string) {
|
||||||
|
this.vaultId = vaultId;
|
||||||
|
this.token = token;
|
||||||
|
this.serverVersion = serverVersion;
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
this.api = new ApiClient(vaultId, token);
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"vaultlink_auth",
|
||||||
|
JSON.stringify({ vaultId, token })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.vaultId = "";
|
||||||
|
this.token = "";
|
||||||
|
this.isAuthenticated = false;
|
||||||
|
this.serverVersion = "";
|
||||||
|
this.api = null;
|
||||||
|
sessionStorage.removeItem("vaultlink_auth");
|
||||||
|
}
|
||||||
|
|
||||||
|
tryRestore(): { vaultId: string; token: string } | null {
|
||||||
|
const stored = sessionStorage.getItem("vaultlink_auth");
|
||||||
|
if (!stored) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(stored) as {
|
||||||
|
vaultId: string;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth = new AuthStore();
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
export type View =
|
||||||
|
| { kind: "dashboard" }
|
||||||
|
| { kind: "document"; documentId: string };
|
||||||
|
|
||||||
|
class NavStore {
|
||||||
|
current = $state<View>({ kind: "dashboard" });
|
||||||
|
|
||||||
|
goto(view: View) {
|
||||||
|
this.current = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
goHome() {
|
||||||
|
this.current = { kind: "dashboard" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nav = new NavStore();
|
||||||
|
|
||||||
|
// Toasts
|
||||||
|
export interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error" | "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToastStore {
|
||||||
|
items = $state<Toast[]>([]);
|
||||||
|
private nextId = 0;
|
||||||
|
|
||||||
|
add(message: string, type: Toast["type"] = "info") {
|
||||||
|
const id = this.nextId++;
|
||||||
|
this.items.push({ id, message, type });
|
||||||
|
setTimeout(() => this.dismiss(id), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(id: number) {
|
||||||
|
this.items = this.items.filter((t) => t.id !== id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toasts = new ToastStore();
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
|
||||||
|
export function inferAction(
|
||||||
|
version: DocumentVersionWithoutContent,
|
||||||
|
previousVersion?: DocumentVersionWithoutContent
|
||||||
|
): ActionType {
|
||||||
|
if (version.isDeleted) return "deleted";
|
||||||
|
if (!previousVersion) return "created";
|
||||||
|
if (
|
||||||
|
previousVersion.isDeleted &&
|
||||||
|
!version.isDeleted
|
||||||
|
)
|
||||||
|
return "restored";
|
||||||
|
if (previousVersion.relativePath !== version.relativePath)
|
||||||
|
return "renamed";
|
||||||
|
return "updated";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enrichVersions(
|
||||||
|
versions: DocumentVersionWithoutContent[]
|
||||||
|
): VersionEvent[] {
|
||||||
|
// versions should be sorted by vaultUpdateId ascending
|
||||||
|
const sorted = [...versions].sort(
|
||||||
|
(a, b) => a.vaultUpdateId - b.vaultUpdateId
|
||||||
|
);
|
||||||
|
const byDoc = new Map<string, DocumentVersionWithoutContent[]>();
|
||||||
|
for (const v of sorted) {
|
||||||
|
let arr = byDoc.get(v.documentId);
|
||||||
|
if (!arr) {
|
||||||
|
arr = [];
|
||||||
|
byDoc.set(v.documentId, arr);
|
||||||
|
}
|
||||||
|
arr.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted.map((v) => {
|
||||||
|
const docVersions = byDoc.get(v.documentId)!;
|
||||||
|
const idx = docVersions.indexOf(v);
|
||||||
|
const prev = idx > 0 ? docVersions[idx - 1] : undefined;
|
||||||
|
const action = inferAction(v, prev);
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
action,
|
||||||
|
previousPath:
|
||||||
|
action === "renamed" ? prev?.relativePath : undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTree(
|
||||||
|
documents: DocumentVersionWithoutContent[],
|
||||||
|
showDeleted: boolean
|
||||||
|
): TreeNode {
|
||||||
|
const root: TreeNode = {
|
||||||
|
name: "",
|
||||||
|
path: "",
|
||||||
|
isFolder: true,
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = showDeleted
|
||||||
|
? documents
|
||||||
|
: documents.filter((d) => !d.isDeleted);
|
||||||
|
|
||||||
|
for (const doc of filtered) {
|
||||||
|
const parts = doc.relativePath.split("/");
|
||||||
|
let current = root;
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const isFile = i === parts.length - 1;
|
||||||
|
const path = parts.slice(0, i + 1).join("/");
|
||||||
|
|
||||||
|
if (isFile) {
|
||||||
|
current.children.push({
|
||||||
|
name: part,
|
||||||
|
path,
|
||||||
|
isFolder: false,
|
||||||
|
children: [],
|
||||||
|
document: doc,
|
||||||
|
isDeleted: doc.isDeleted
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let folder = current.children.find(
|
||||||
|
(c) => c.isFolder && c.name === part
|
||||||
|
);
|
||||||
|
if (!folder) {
|
||||||
|
folder = {
|
||||||
|
name: part,
|
||||||
|
path,
|
||||||
|
isFolder: true,
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
current.children.push(folder);
|
||||||
|
}
|
||||||
|
current = folder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sortTree(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTree(node: TreeNode) {
|
||||||
|
node.children.sort((a, b) => {
|
||||||
|
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.isFolder) sortTree(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativeTime(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - date.getTime();
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (seconds < 60) return "just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: days > 365 ? "numeric" : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function absoluteTime(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileExtension(path: string): string {
|
||||||
|
const dot = path.lastIndexOf(".");
|
||||||
|
return dot > -1 ? path.substring(dot + 1).toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTextFile(path: string): boolean {
|
||||||
|
const textExts = new Set([
|
||||||
|
"md",
|
||||||
|
"txt",
|
||||||
|
"json",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"toml",
|
||||||
|
"xml",
|
||||||
|
"html",
|
||||||
|
"css",
|
||||||
|
"js",
|
||||||
|
"ts",
|
||||||
|
"svelte",
|
||||||
|
"rs",
|
||||||
|
"py",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"csv",
|
||||||
|
"svg",
|
||||||
|
"log",
|
||||||
|
"conf",
|
||||||
|
"cfg",
|
||||||
|
"ini",
|
||||||
|
"env",
|
||||||
|
"gitignore",
|
||||||
|
"editorconfig"
|
||||||
|
]);
|
||||||
|
return textExts.has(fileExtension(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageFile(path: string): boolean {
|
||||||
|
const imageExts = new Set([
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"gif",
|
||||||
|
"webp",
|
||||||
|
"svg",
|
||||||
|
"ico",
|
||||||
|
"bmp"
|
||||||
|
]);
|
||||||
|
return imageExts.has(fileExtension(path));
|
||||||
|
}
|
||||||
54
frontend/history-ui/src/lib/types.ts
Normal file
54
frontend/history-ui/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
export interface DocumentVersionWithoutContent {
|
||||||
|
vaultUpdateId: number;
|
||||||
|
documentId: string;
|
||||||
|
relativePath: string;
|
||||||
|
updatedDate: string;
|
||||||
|
isDeleted: boolean;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
contentSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentVersion {
|
||||||
|
vaultUpdateId: number;
|
||||||
|
documentId: string;
|
||||||
|
relativePath: string;
|
||||||
|
updatedDate: string;
|
||||||
|
contentBase64: string;
|
||||||
|
isDeleted: boolean;
|
||||||
|
userId: string;
|
||||||
|
deviceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FetchLatestDocumentsResponse {
|
||||||
|
latestDocuments: DocumentVersionWithoutContent[];
|
||||||
|
lastUpdateId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VaultHistoryResponse {
|
||||||
|
versions: DocumentVersionWithoutContent[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PingResponse {
|
||||||
|
serverVersion: string;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
mergeableFileExtensions: string[];
|
||||||
|
supportedApiVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionType = "created" | "updated" | "renamed" | "deleted" | "restored";
|
||||||
|
|
||||||
|
export interface VersionEvent extends DocumentVersionWithoutContent {
|
||||||
|
action: ActionType;
|
||||||
|
previousPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeNode {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isFolder: boolean;
|
||||||
|
children: TreeNode[];
|
||||||
|
document?: DocumentVersionWithoutContent;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
}
|
||||||
7
frontend/history-ui/src/main.ts
Normal file
7
frontend/history-ui/src/main.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { mount } from "svelte";
|
||||||
|
import App from "./App.svelte";
|
||||||
|
import "./app.css";
|
||||||
|
|
||||||
|
const app = mount(App, { target: document.getElementById("app")! });
|
||||||
|
|
||||||
|
export default app;
|
||||||
5
frontend/history-ui/svelte.config.js
Normal file
5
frontend/history-ui/svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess()
|
||||||
|
};
|
||||||
16
frontend/history-ui/tsconfig.json
Normal file
16
frontend/history-ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"types": ["svelte"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
15
frontend/history-ui/vite.config.ts
Normal file
15
frontend/history-ui/vite.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/vaults": "http://localhost:3011"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:22-slim AS builder
|
FROM node:25-slim AS builder
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@ COPY . .
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-alpine
|
FROM node:25-alpine
|
||||||
|
|
||||||
LABEL org.opencontainers.image.title="VaultLink Local CLI"
|
LABEL org.opencontainers.image.title="VaultLink Local CLI"
|
||||||
LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client"
|
LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client"
|
||||||
|
|
|
||||||
|
|
@ -47,24 +47,25 @@ vaultlink \
|
||||||
|
|
||||||
### Required
|
### Required
|
||||||
|
|
||||||
| Option | Description |
|
| Option | Description |
|
||||||
|--------|-------------|
|
| ------------------------- | --------------------------------------------- |
|
||||||
| `-l, --local-path <path>` | Local directory to sync |
|
| `-l, --local-path <path>` | Local directory to sync |
|
||||||
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
|
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
|
||||||
| `-t, --token <token>` | Authentication token |
|
| `-t, --token <token>` | Authentication token |
|
||||||
| `-v, --vault-name <name>` | Vault name on server |
|
| `-v, --vault-name <name>` | Vault name on server |
|
||||||
|
|
||||||
### Optional
|
### Optional
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
| ------------------------------------ | ------- | ---------------------------------------------------- |
|
||||||
| `--sync-concurrency <number>` | `1` | Concurrent sync operations |
|
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
|
||||||
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
|
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
|
||||||
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
|
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
|
||||||
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
|
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
||||||
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
| `--line-endings <mode>` | `auto` | Line ending style: auto, lf, crlf |
|
||||||
| `-h, --help` | - | Show help |
|
| `-q, --quiet` | - | Suppress startup banner for non-interactive use |
|
||||||
| `-V, --version` | - | Show version |
|
| `-h, --help` | - | Show help |
|
||||||
|
| `-V, --version` | - | Show version |
|
||||||
|
|
||||||
### Auto-Ignored Patterns
|
### Auto-Ignored Patterns
|
||||||
|
|
||||||
|
|
@ -74,22 +75,32 @@ vaultlink \
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
Basic usage:
|
Basic usage:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default
|
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default
|
||||||
```
|
```
|
||||||
|
|
||||||
With ignore patterns:
|
With ignore patterns:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||||
--ignore-pattern "*.tmp" \
|
--ignore-pattern "**/*.tmp" \
|
||||||
--ignore-pattern ".DS_Store" \
|
--ignore-pattern ".DS_Store" \
|
||||||
--ignore-pattern "node_modules/**"
|
--ignore-pattern "node_modules/**"
|
||||||
```
|
```
|
||||||
|
|
||||||
With debug logging:
|
With debug logging and quiet startup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||||
--log-level DEBUG
|
--log-level DEBUG --quiet
|
||||||
|
```
|
||||||
|
|
||||||
|
Force LF line endings (useful for cross-platform vaults):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||||
|
--line-endings lf
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker Deployment
|
## Docker Deployment
|
||||||
|
|
@ -176,6 +187,7 @@ services:
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Build:
|
Build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
# or from the parent folder, run
|
# or from the parent folder, run
|
||||||
|
|
@ -183,11 +195,13 @@ docker build -f local-client-cli/Dockerfile .
|
||||||
```
|
```
|
||||||
|
|
||||||
Test:
|
Test:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker build:
|
Docker build:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .
|
docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,16 @@
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"test": "tsx --test 'src/**/*.test.ts'"
|
"test": "tsx --test 'src/**/*.test.ts'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
|
||||||
"commander": "^14.0.2",
|
|
||||||
"watcher": "^2.3.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.8.1",
|
"commander": "^14.0.2",
|
||||||
|
"watcher": "^2.3.1",
|
||||||
|
"@types/node": "^25.0.2",
|
||||||
"sync-client": "file:../sync-client",
|
"sync-client": "file:../sync-client",
|
||||||
"ts-loader": "^9.5.2",
|
"ts-loader": "^9.5.4",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.21.0",
|
||||||
|
|
|||||||
"typescript": "5.8.3",
|
"typescript": "5.9.3",
|
||||||
"webpack": "^5.99.9",
|
"webpack": "^5.103.0",
|
||||||
"webpack-cli": "^6.0.1"
|
"webpack-cli": "^6.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,13 +55,10 @@ test("parseArgs - parse with optional arguments", () => {
|
||||||
"mytoken",
|
"mytoken",
|
||||||
"-v",
|
"-v",
|
||||||
"default",
|
"default",
|
||||||
"--sync-concurrency",
|
|
||||||
"5",
|
|
||||||
"--max-file-size-mb",
|
"--max-file-size-mb",
|
||||||
"20"
|
"20"
|
||||||
]);
|
]);
|
||||||
|
|
||||||
assert.equal(args.syncConcurrency, 5);
|
|
||||||
assert.equal(args.maxFileSizeMB, 20);
|
assert.equal(args.maxFileSizeMB, 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -228,3 +225,226 @@ test("parseArgs - throws on invalid log level", () => {
|
||||||
]);
|
]);
|
||||||
}, /Invalid log level/);
|
}, /Invalid log level/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("parseArgs - reads required options from environment variables", () => {
|
||||||
|
process.env.VAULTLINK_LOCAL_PATH = "/env/path";
|
||||||
|
process.env.VAULTLINK_REMOTE_URI = "https://env.example.com";
|
||||||
|
process.env.VAULTLINK_TOKEN = "env-token";
|
||||||
|
process.env.VAULTLINK_VAULT_NAME = "env-vault";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = parseArgs(["node", "cli.js"]);
|
||||||
|
assert.equal(args.localPath, "/env/path");
|
||||||
|
assert.equal(args.remoteUri, "https://env.example.com");
|
||||||
|
assert.equal(args.token, "env-token");
|
||||||
|
assert.equal(args.vaultName, "env-vault");
|
||||||
|
} finally {
|
||||||
|
delete process.env.VAULTLINK_LOCAL_PATH;
|
||||||
|
delete process.env.VAULTLINK_REMOTE_URI;
|
||||||
|
delete process.env.VAULTLINK_TOKEN;
|
||||||
|
delete process.env.VAULTLINK_VAULT_NAME;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - CLI arguments take precedence over environment variables", () => {
|
||||||
|
process.env.VAULTLINK_TOKEN = "env-token";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"https://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"cli-token",
|
||||||
|
"-v",
|
||||||
|
"default"
|
||||||
|
]);
|
||||||
|
assert.equal(args.token, "cli-token");
|
||||||
|
} finally {
|
||||||
|
delete process.env.VAULTLINK_TOKEN;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - reads log level from environment variable", () => {
|
||||||
|
process.env.VAULTLINK_LOG_LEVEL = "DEBUG";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"https://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default"
|
||||||
|
]);
|
||||||
|
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||||
|
} finally {
|
||||||
|
delete process.env.VAULTLINK_LOG_LEVEL;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - quiet defaults to false", () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"https://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default"
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.quiet, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - parse --quiet flag", () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"https://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default",
|
||||||
|
"--quiet"
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.quiet, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - parse -q short flag", () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"https://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default",
|
||||||
|
"-q"
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.quiet, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - line-endings defaults to auto", () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"https://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default"
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.lineEndings, "auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - parse --line-endings lf", () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"https://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default",
|
||||||
|
"--line-endings",
|
||||||
|
"lf"
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.lineEndings, "lf");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - parse --line-endings crlf", () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"https://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default",
|
||||||
|
"--line-endings",
|
||||||
|
"crlf"
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.lineEndings, "crlf");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - throws on invalid remote URI protocol", () => {
|
||||||
|
assert.throws(() => {
|
||||||
|
parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"ftp://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default"
|
||||||
|
]);
|
||||||
|
}, /Invalid remote URI/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - accepts http:// remote URI", () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default"
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.remoteUri, "http://localhost:3000");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseArgs - accepts wss:// remote URI", () => {
|
||||||
|
const args = parseArgs([
|
||||||
|
"node",
|
||||||
|
"cli.js",
|
||||||
|
"-l",
|
||||||
|
"/path/to/vault",
|
||||||
|
"-r",
|
||||||
|
"wss://sync.example.com",
|
||||||
|
"-t",
|
||||||
|
"mytoken",
|
||||||
|
"-v",
|
||||||
|
"default"
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(args.remoteUri, "wss://sync.example.com");
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
import { Command } from "commander";
|
import { Command, Option } from "commander";
|
||||||
import packageJson from "../package.json";
|
import packageJson from "../package.json";
|
||||||
import { LogLevel } from "sync-client";
|
import { LogLevel } from "sync-client";
|
||||||
|
|
||||||
|
export type LineEndingMode = "auto" | "lf" | "crlf";
|
||||||
|
|
||||||
export interface CliArgs {
|
export interface CliArgs {
|
||||||
remoteUri: string;
|
remoteUri: string;
|
||||||
token: string;
|
token: string;
|
||||||
vaultName: string;
|
vaultName: string;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
syncConcurrency?: number;
|
|
||||||
maxFileSizeMB?: number;
|
maxFileSizeMB?: number;
|
||||||
ignorePatterns?: string[];
|
ignorePatterns?: string[];
|
||||||
webSocketRetryIntervalMs?: number;
|
webSocketRetryIntervalMs?: number;
|
||||||
logLevel: LogLevel;
|
logLevel: LogLevel;
|
||||||
health?: string;
|
health?: string;
|
||||||
enableTelemetry?: boolean;
|
enableTelemetry?: boolean;
|
||||||
|
quiet: boolean;
|
||||||
|
lineEndings: LineEndingMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_URI_PREFIXES = ["http://", "https://", "ws://", "wss://"];
|
||||||
|
|
||||||
export function parseArgs(argv: string[]): CliArgs {
|
export function parseArgs(argv: string[]): CliArgs {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
|
|
||||||
|
|
@ -25,41 +30,86 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||||
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
||||||
)
|
)
|
||||||
.version(packageJson.version)
|
.version(packageJson.version)
|
||||||
.option("-l, --local-path <path>", "Local directory path to sync")
|
.addOption(
|
||||||
.option("-r, --remote-uri <uri>", "Remote server URI")
|
new Option(
|
||||||
.option("-t, --token <token>", "Authentication token")
|
"-l, --local-path <path>",
|
||||||
.option("-v, --vault-name <name>", "Vault name")
|
"Local directory path to sync"
|
||||||
.option(
|
).env("VAULTLINK_LOCAL_PATH")
|
||||||
"--sync-concurrency <number>",
|
|
||||||
"[OPTIONAL] Number of concurrent sync operations",
|
|
||||||
parseInt
|
|
||||||
)
|
)
|
||||||
.option(
|
.addOption(
|
||||||
"--max-file-size-mb <number>",
|
new Option(
|
||||||
"[OPTIONAL] Maximum file size in MB",
|
"-r, --remote-uri <uri>",
|
||||||
parseInt
|
"Remote server URI"
|
||||||
|
).env("VAULTLINK_REMOTE_URI")
|
||||||
)
|
)
|
||||||
.option(
|
.addOption(
|
||||||
"--ignore-pattern <pattern...>",
|
new Option(
|
||||||
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
"-t, --token <token>",
|
||||||
|
"Authentication token"
|
||||||
|
).env("VAULTLINK_TOKEN")
|
||||||
)
|
)
|
||||||
.option(
|
.addOption(
|
||||||
"--websocket-retry-interval-ms <number>",
|
new Option(
|
||||||
"[OPTIONAL] WebSocket retry interval in milliseconds",
|
"-v, --vault-name <name>",
|
||||||
parseInt
|
"Vault name"
|
||||||
|
).env("VAULTLINK_VAULT_NAME")
|
||||||
)
|
)
|
||||||
.option(
|
.addOption(
|
||||||
"--log-level <level>",
|
new Option(
|
||||||
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
"--max-file-size-mb <number>",
|
||||||
"INFO"
|
"[OPTIONAL] Maximum file size in MB"
|
||||||
|
)
|
||||||
|
.argParser(parseInt)
|
||||||
|
.env("VAULTLINK_MAX_FILE_SIZE_MB")
|
||||||
)
|
)
|
||||||
.option(
|
.addOption(
|
||||||
"--health <path>",
|
new Option(
|
||||||
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
"--ignore-pattern <pattern...>",
|
||||||
|
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
||||||
|
).env("VAULTLINK_IGNORE_PATTERNS")
|
||||||
)
|
)
|
||||||
.option(
|
.addOption(
|
||||||
"--enable-telemetry",
|
new Option(
|
||||||
"[OPTIONAL] Enable telemetry (disabled by default)"
|
"--websocket-retry-interval-ms <number>",
|
||||||
|
"[OPTIONAL] WebSocket retry interval in milliseconds"
|
||||||
|
)
|
||||||
|
.argParser(parseInt)
|
||||||
|
.env("VAULTLINK_WEBSOCKET_RETRY_INTERVAL_MS")
|
||||||
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
|
"--log-level <level>",
|
||||||
|
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)"
|
||||||
|
)
|
||||||
|
.default("INFO")
|
||||||
|
.env("VAULTLINK_LOG_LEVEL")
|
||||||
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
|
"--health <path>",
|
||||||
|
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
||||||
|
).env("VAULTLINK_HEALTH")
|
||||||
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
|
"--enable-telemetry",
|
||||||
|
"[OPTIONAL] Enable telemetry (disabled by default)"
|
||||||
|
).env("VAULTLINK_ENABLE_TELEMETRY")
|
||||||
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
|
"-q, --quiet",
|
||||||
|
"[OPTIONAL] Suppress startup banner for non-interactive use"
|
||||||
|
).env("VAULTLINK_QUIET")
|
||||||
|
)
|
||||||
|
.addOption(
|
||||||
|
new Option(
|
||||||
|
"--line-endings <mode>",
|
||||||
|
"[OPTIONAL] Line ending style: auto (platform default), lf, crlf"
|
||||||
|
)
|
||||||
|
.default("auto")
|
||||||
|
.choices(["auto", "lf", "crlf"])
|
||||||
|
.env("VAULTLINK_LINE_ENDINGS")
|
||||||
)
|
)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
|
|
@ -67,9 +117,13 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||||
Examples:
|
Examples:
|
||||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default
|
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default
|
||||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
||||||
--ignore-pattern ".git/**" --ignore-pattern "*.tmp"
|
--ignore-pattern ".git/**" --ignore-pattern "**/*.tmp"
|
||||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
||||||
--log-level DEBUG
|
--log-level DEBUG --quiet
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
All options can be configured via VAULTLINK_ prefixed environment variables.
|
||||||
|
CLI arguments take precedence over environment variables.
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -81,7 +135,6 @@ Examples:
|
||||||
const remoteUri = opts.remoteUri as string | undefined;
|
const remoteUri = opts.remoteUri as string | undefined;
|
||||||
const token = opts.token as string | undefined;
|
const token = opts.token as string | undefined;
|
||||||
const vaultName = opts.vaultName as string | undefined;
|
const vaultName = opts.vaultName as string | undefined;
|
||||||
const syncConcurrency = opts.syncConcurrency as number | undefined;
|
|
||||||
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
||||||
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
||||||
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
||||||
|
|
@ -90,22 +143,44 @@ Examples:
|
||||||
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
||||||
const health = opts.health as string | undefined;
|
const health = opts.health as string | undefined;
|
||||||
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
||||||
|
const quiet = (opts.quiet as boolean | undefined) ?? false;
|
||||||
|
const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto";
|
||||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||||
|
|
||||||
if (localPath === undefined) {
|
const requireOption = <T>(
|
||||||
|
value: T | undefined,
|
||||||
|
name: string
|
||||||
|
): T => {
|
||||||
|
if (value === undefined) {
|
||||||
|
const option = program.options.find(
|
||||||
|
(o) => o.attributeName() === name
|
||||||
|
);
|
||||||
|
const envHint =
|
||||||
|
option?.envVar !== undefined
|
||||||
|
? ` (or set ${option.envVar})`
|
||||||
|
: "";
|
||||||
|
throw new Error(
|
||||||
|
`required option '${option?.flags ?? name}' not specified${envHint}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requiredLocalPath = requireOption(localPath, "localPath");
|
||||||
|
const requiredRemoteUri = requireOption(remoteUri, "remoteUri");
|
||||||
|
const requiredToken = requireOption(token, "token");
|
||||||
|
const requiredVaultName = requireOption(vaultName, "vaultName");
|
||||||
|
|
||||||
|
// Validate remote URI protocol
|
||||||
|
if (
|
||||||
|
!VALID_URI_PREFIXES.some((prefix) =>
|
||||||
|
requiredRemoteUri.startsWith(prefix)
|
||||||
|
)
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"required option '-l, --local-path <path>' not specified"
|
`Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_URI_PREFIXES.join(", ")}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (remoteUri === undefined) {
|
|
||||||
throw new Error("required option '--remote-uri <uri>' not specified");
|
|
||||||
}
|
|
||||||
if (token === undefined) {
|
|
||||||
throw new Error("required option '--token <token>' not specified");
|
|
||||||
}
|
|
||||||
if (vaultName === undefined) {
|
|
||||||
throw new Error("required option '--vault-name <name>' not specified");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate and parse log level
|
// Validate and parse log level
|
||||||
const logLevelUpper = logLevelStr.toUpperCase();
|
const logLevelUpper = logLevelStr.toUpperCase();
|
||||||
|
|
@ -120,17 +195,21 @@ Examples:
|
||||||
}
|
}
|
||||||
const logLevel = logLevelUpper;
|
const logLevel = logLevelUpper;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
const lineEndings = lineEndingsStr as LineEndingMode;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
localPath,
|
localPath: requiredLocalPath,
|
||||||
remoteUri,
|
remoteUri: requiredRemoteUri,
|
||||||
token,
|
token: requiredToken,
|
||||||
vaultName,
|
vaultName: requiredVaultName,
|
||||||
syncConcurrency,
|
|
||||||
maxFileSizeMB: maxFileSizeMb,
|
maxFileSizeMB: maxFileSizeMb,
|
||||||
ignorePatterns: ignorePattern,
|
ignorePatterns: ignorePattern,
|
||||||
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
||||||
logLevel,
|
logLevel,
|
||||||
health,
|
health,
|
||||||
enableTelemetry
|
enableTelemetry,
|
||||||
|
quiet,
|
||||||
|
lineEndings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable no-console */
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import * as fs from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
import * as fsSync from "fs";
|
import * as fsSync from "fs";
|
||||||
|
|
@ -36,6 +37,20 @@ const LOG_LEVEL_ORDER = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const HEALTH_CHECK_INTERVAL_MS = 30 * 1000;
|
const HEALTH_CHECK_INTERVAL_MS = 30 * 1000;
|
||||||
|
const PROGRESS_LOG_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
|
function resolveLineEndings(
|
||||||
|
mode: "auto" | "lf" | "crlf"
|
||||||
|
): string {
|
||||||
|
switch (mode) {
|
||||||
|
case "lf":
|
||||||
|
return "\n";
|
||||||
|
case "crlf":
|
||||||
|
return "\r\n";
|
||||||
|
case "auto":
|
||||||
|
return process.platform === "win32" ? "\r\n" : "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const args = parseArgs(process.argv);
|
const args = parseArgs(process.argv);
|
||||||
|
|
@ -63,21 +78,28 @@ async function main(): Promise<void> {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
if (!args.quiet) {
|
||||||
styleText("VaultLink Local CLI", "bold", "cyan") +
|
console.log(
|
||||||
colorize(` v${packageJson.version}`, "dim")
|
styleText("VaultLink Local CLI", "bold", "cyan") +
|
||||||
);
|
colorize(` v${packageJson.version}`, "dim")
|
||||||
console.log(colorize("=".repeat(50), "dim"));
|
);
|
||||||
console.log(
|
console.log(colorize("=".repeat(50), "dim"));
|
||||||
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
|
console.log(
|
||||||
);
|
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
|
||||||
console.log(
|
);
|
||||||
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
|
console.log(
|
||||||
);
|
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
|
||||||
console.log(
|
);
|
||||||
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
|
console.log(
|
||||||
);
|
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
|
||||||
console.log("");
|
);
|
||||||
|
if (args.lineEndings !== "auto") {
|
||||||
|
console.log(
|
||||||
|
`${colorize("Line endings:", "dim")} ${colorize(args.lineEndings.toUpperCase(), "green")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
const dataDir = path.join(absolutePath, ".vaultlink");
|
const dataDir = path.join(absolutePath, ".vaultlink");
|
||||||
const dataFile = path.join(dataDir, "sync-data.json");
|
const dataFile = path.join(dataDir, "sync-data.json");
|
||||||
|
|
@ -97,8 +119,6 @@ async function main(): Promise<void> {
|
||||||
remoteUri: args.remoteUri,
|
remoteUri: args.remoteUri,
|
||||||
token: args.token,
|
token: args.token,
|
||||||
vaultName: args.vaultName,
|
vaultName: args.vaultName,
|
||||||
syncConcurrency:
|
|
||||||
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
|
|
||||||
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
|
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
|
||||||
ignorePatterns,
|
ignorePatterns,
|
||||||
webSocketRetryIntervalMs:
|
webSocketRetryIntervalMs:
|
||||||
|
|
@ -140,16 +160,21 @@ async function main(): Promise<void> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
|
nativeLineEndings: resolveLineEndings(args.lineEndings)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (args.health !== undefined) {
|
if (args.health !== undefined) {
|
||||||
const healthFile = args.health;
|
const healthFile = args.health;
|
||||||
const healthInterval = setInterval(() => {
|
const writeHealth = (): void => {
|
||||||
void client.checkConnection().then((status) => {
|
void client.checkConnection().then((status) => {
|
||||||
writeHealthStatus(healthFile, status);
|
writeHealthStatus(healthFile, status);
|
||||||
});
|
});
|
||||||
}, HEALTH_CHECK_INTERVAL_MS);
|
};
|
||||||
|
writeHealth();
|
||||||
|
const healthInterval = setInterval(
|
||||||
|
writeHealth,
|
||||||
|
HEALTH_CHECK_INTERVAL_MS
|
||||||
|
);
|
||||||
const clearHealthInterval = (): void => {
|
const clearHealthInterval = (): void => {
|
||||||
clearInterval(healthInterval);
|
clearInterval(healthInterval);
|
||||||
};
|
};
|
||||||
|
|
@ -168,7 +193,7 @@ async function main(): Promise<void> {
|
||||||
|
|
||||||
client.logger.info("Starting sync client");
|
client.logger.info("Starting sync client");
|
||||||
|
|
||||||
const fileWatcher = new FileWatcher(absolutePath, client);
|
const fileWatcher = new FileWatcher(absolutePath, client, ignorePatterns);
|
||||||
|
|
||||||
client.onWebSocketStatusChanged.add(() => {
|
client.onWebSocketStatusChanged.add(() => {
|
||||||
const isConnected = client.isWebSocketConnected;
|
const isConnected = client.isWebSocketConnected;
|
||||||
|
|
@ -177,15 +202,42 @@ async function main(): Promise<void> {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Throttled progress reporting
|
||||||
|
let syncBatchSize = 0;
|
||||||
|
let totalSyncOps = 0;
|
||||||
|
let lastProgressLogTime = 0;
|
||||||
|
|
||||||
client.onRemainingOperationsCountChanged.add((remaining) => {
|
client.onRemainingOperationsCountChanged.add((remaining) => {
|
||||||
|
if (remaining > syncBatchSize) {
|
||||||
|
syncBatchSize = remaining;
|
||||||
|
}
|
||||||
|
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
client.logger.info("All sync operations completed");
|
if (syncBatchSize > 0) {
|
||||||
|
totalSyncOps += syncBatchSize;
|
||||||
|
client.logger.info(
|
||||||
|
`Sync batch complete (${syncBatchSize} operations)`
|
||||||
|
);
|
||||||
|
syncBatchSize = 0;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
client.logger.info(`${remaining} sync operations remaining`);
|
const now = Date.now();
|
||||||
|
if (now - lastProgressLogTime >= PROGRESS_LOG_INTERVAL_MS) {
|
||||||
|
client.logger.info(
|
||||||
|
`Syncing: ${remaining} operations remaining`
|
||||||
|
);
|
||||||
|
lastProgressLogTime = now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let isShuttingDown = false;
|
||||||
const gracefulShutdown = async (signal: string): Promise<void> => {
|
const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||||
|
if (isShuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isShuttingDown = true;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
colorize(
|
colorize(
|
||||||
`\n${signal} received. Shutting down gracefully...`,
|
`\n${signal} received. Shutting down gracefully...`,
|
||||||
|
|
@ -196,7 +248,17 @@ async function main(): Promise<void> {
|
||||||
fileWatcher.stop();
|
fileWatcher.stop();
|
||||||
await client.waitUntilFinished();
|
await client.waitUntilFinished();
|
||||||
await client.destroy();
|
await client.destroy();
|
||||||
console.log(colorize("Shutdown complete", "green"));
|
|
||||||
|
if (totalSyncOps > 0) {
|
||||||
|
console.log(
|
||||||
|
colorize(
|
||||||
|
`Shutdown complete (${totalSyncOps} operations synced)`,
|
||||||
|
"green"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(colorize("Shutdown complete", "green"));
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -219,9 +281,13 @@ async function main(): Promise<void> {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`${colorize("✓", "green")} Server connection successful`);
|
if (!args.quiet) {
|
||||||
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
console.log(
|
||||||
console.log("");
|
`${colorize("✓", "green")} Server connection successful`
|
||||||
|
);
|
||||||
|
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
await client.start();
|
await client.start();
|
||||||
fileWatcher.start();
|
fileWatcher.start();
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
import Watcher from "watcher";
|
import Watcher from "watcher";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import type { SyncClient, RelativePath } from "sync-client";
|
import type { SyncClient, RelativePath } from "sync-client";
|
||||||
|
import { toUnixPath, compileGlobPattern } from "./path-utils";
|
||||||
|
|
||||||
export class FileWatcher {
|
export class FileWatcher {
|
||||||
private watcher: Watcher | undefined;
|
private watcher: Watcher | undefined;
|
||||||
private isRunning = false;
|
private isRunning = false;
|
||||||
|
private readonly compiledPatterns: RegExp[];
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly basePath: string,
|
private readonly basePath: string,
|
||||||
private readonly client: SyncClient
|
private readonly client: SyncClient,
|
||||||
) {}
|
ignorePatterns: string[] = []
|
||||||
|
) {
|
||||||
|
this.compiledPatterns = ignorePatterns.map(compileGlobPattern);
|
||||||
|
}
|
||||||
|
|
||||||
public start(): void {
|
public start(): void {
|
||||||
if (this.isRunning) {
|
if (this.isRunning) {
|
||||||
|
|
@ -22,7 +27,9 @@ export class FileWatcher {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
renameDetection: true,
|
renameDetection: true,
|
||||||
renameTimeout: 125,
|
renameTimeout: 125,
|
||||||
ignoreInitial: true
|
ignoreInitial: true,
|
||||||
|
ignore: (filePath: string): boolean =>
|
||||||
|
this.shouldIgnore(filePath)
|
||||||
});
|
});
|
||||||
|
|
||||||
this.watcher.on("add", (filePath: string) => {
|
this.watcher.on("add", (filePath: string) => {
|
||||||
|
|
@ -56,6 +63,11 @@ export class FileWatcher {
|
||||||
this.client.logger.info("File watcher stopped");
|
this.client.logger.info("File watcher stopped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldIgnore(filePath: string): boolean {
|
||||||
|
const rel = toUnixPath(path.relative(this.basePath, filePath));
|
||||||
|
return this.compiledPatterns.some((regex) => regex.test(rel));
|
||||||
|
}
|
||||||
|
|
||||||
private handleCreate(relativePath: RelativePath): void {
|
private handleCreate(relativePath: RelativePath): void {
|
||||||
this.client
|
this.client
|
||||||
.syncLocallyCreatedFile(relativePath)
|
.syncLocallyCreatedFile(relativePath)
|
||||||
|
|
@ -101,18 +113,7 @@ export class FileWatcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
private toRelativePath(absolutePath: string): RelativePath {
|
private toRelativePath(absolutePath: string): RelativePath {
|
||||||
const relative = path.relative(this.basePath, absolutePath);
|
return toUnixPath(path.relative(this.basePath, absolutePath));
|
||||||
return this.toUnixPath(relative);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a native platform path to forward slashes
|
|
||||||
*/
|
|
||||||
private toUnixPath(nativePath: string): string {
|
|
||||||
if (path.sep === "\\") {
|
|
||||||
return nativePath.replace(/\\/g, "/");
|
|
||||||
}
|
|
||||||
return nativePath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatError(err: unknown): string {
|
private formatError(err: unknown): string {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Healthcheck script for Docker container
|
* Healthcheck script for Docker container
|
||||||
|
|
|
||||||
70
frontend/local-client-cli/src/logger-formatter.test.ts
Normal file
70
frontend/local-client-cli/src/logger-formatter.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { test } from "node:test";
|
||||||
|
import * as assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
colorize,
|
||||||
|
styleText,
|
||||||
|
formatLogLine,
|
||||||
|
colors
|
||||||
|
} from "./logger-formatter";
|
||||||
|
import { LogLevel } from "sync-client";
|
||||||
|
|
||||||
|
test("colorize - wraps text with ANSI color codes", () => {
|
||||||
|
const result = colorize("hello", "red");
|
||||||
|
assert.equal(result, `${colors.red}hello${colors.reset}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("styleText - applies multiple modifiers", () => {
|
||||||
|
const result = styleText("hello", "bold", "cyan");
|
||||||
|
assert.equal(
|
||||||
|
result,
|
||||||
|
`${colors.bold}${colors.cyan}hello${colors.reset}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatLogLine - includes level and message", () => {
|
||||||
|
const logLine = {
|
||||||
|
timestamp: new Date("2024-01-15T10:30:45.123Z"),
|
||||||
|
level: LogLevel.INFO,
|
||||||
|
message: "Test message"
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatLogLine(logLine);
|
||||||
|
assert.ok(result.includes("INFO"));
|
||||||
|
assert.ok(result.includes("Test message"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatLogLine - ERROR level messages contain bold escape", () => {
|
||||||
|
const logLine = {
|
||||||
|
timestamp: new Date("2024-01-15T10:30:45.123Z"),
|
||||||
|
level: LogLevel.ERROR,
|
||||||
|
message: "Error occurred"
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatLogLine(logLine);
|
||||||
|
assert.ok(result.includes(colors.bold));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatLogLine - highlights file paths in quotes", () => {
|
||||||
|
const logLine = {
|
||||||
|
timestamp: new Date("2024-01-15T10:30:45.123Z"),
|
||||||
|
level: LogLevel.INFO,
|
||||||
|
message: 'Syncing "notes/test.md"'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatLogLine(logLine);
|
||||||
|
assert.ok(result.includes(colors.magenta));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("formatLogLine - highlights standalone numbers but not numbers in versions", () => {
|
||||||
|
const logLine = {
|
||||||
|
timestamp: new Date("2024-01-15T10:30:45.123Z"),
|
||||||
|
level: LogLevel.INFO,
|
||||||
|
message: "Listed 42 files from v1.2.3"
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = formatLogLine(logLine);
|
||||||
|
// "42" should be colorized (standalone number)
|
||||||
|
assert.ok(result.includes(`${colors.cyan}42${colors.reset}`));
|
||||||
|
// "1", "2", "3" in "v1.2.3" should NOT be colorized individually
|
||||||
|
assert.ok(!result.includes(`${colors.cyan}1${colors.reset}.`));
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,7 @@ import type {
|
||||||
RelativePath,
|
RelativePath,
|
||||||
TextWithCursors
|
TextWithCursors
|
||||||
} from "sync-client";
|
} from "sync-client";
|
||||||
|
import { toUnixPath, toNativePath } from "./path-utils";
|
||||||
|
|
||||||
export class NodeFileSystemOperations implements FileSystemOperations {
|
export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public constructor(private readonly basePath: string) {}
|
public constructor(private readonly basePath: string) {}
|
||||||
|
|
@ -15,7 +16,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
): Promise<RelativePath[]> {
|
): Promise<RelativePath[]> {
|
||||||
const files: RelativePath[] = [];
|
const files: RelativePath[] = [];
|
||||||
await this.walkDirectory(
|
await this.walkDirectory(
|
||||||
directory !== undefined ? this.toNativePath(directory) : "",
|
directory !== undefined ? toNativePath(directory) : "",
|
||||||
files
|
files
|
||||||
);
|
);
|
||||||
return files;
|
return files;
|
||||||
|
|
@ -24,7 +25,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(fullPath);
|
return await fs.readFile(fullPath);
|
||||||
|
|
@ -41,13 +42,13 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
const dir = path.dirname(fullPath);
|
const dir = path.dirname(fullPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await fs.writeFile(fullPath, content);
|
await this.atomicWrite(fullPath, content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
|
@ -61,13 +62,13 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const currentContent = await fs.readFile(fullPath, "utf-8");
|
const currentContent = await fs.readFile(fullPath, "utf-8");
|
||||||
const result = updater({ text: currentContent, cursors: [] });
|
const result = updater({ text: currentContent, cursors: [] });
|
||||||
await fs.writeFile(fullPath, result.text, "utf-8");
|
await this.atomicWrite(fullPath, result.text, "utf-8");
|
||||||
return result.text;
|
return result.text;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -79,7 +80,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(fullPath);
|
const stats = await fs.stat(fullPath);
|
||||||
|
|
@ -94,7 +95,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public async exists(relativePath: RelativePath): Promise<boolean> {
|
public async exists(relativePath: RelativePath): Promise<boolean> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.access(fullPath);
|
await fs.access(fullPath);
|
||||||
|
|
@ -107,7 +108,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(fullPath, { recursive: false });
|
await fs.mkdir(fullPath, { recursive: false });
|
||||||
|
|
@ -121,7 +122,7 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
public async delete(relativePath: RelativePath): Promise<void> {
|
public async delete(relativePath: RelativePath): Promise<void> {
|
||||||
const fullPath = path.join(
|
const fullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(relativePath)
|
toNativePath(relativePath)
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(fullPath);
|
await fs.unlink(fullPath);
|
||||||
|
|
@ -138,11 +139,11 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const oldFullPath = path.join(
|
const oldFullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(oldPath)
|
toNativePath(oldPath)
|
||||||
);
|
);
|
||||||
const newFullPath = path.join(
|
const newFullPath = path.join(
|
||||||
this.basePath,
|
this.basePath,
|
||||||
this.toNativePath(newPath)
|
toNativePath(newPath)
|
||||||
);
|
);
|
||||||
const newDir = path.dirname(newFullPath);
|
const newDir = path.dirname(newFullPath);
|
||||||
|
|
||||||
|
|
@ -156,6 +157,19 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async atomicWrite(
|
||||||
|
fullPath: string,
|
||||||
|
content: Uint8Array | string,
|
||||||
|
encoding?: BufferEncoding
|
||||||
|
): Promise<void> {
|
||||||
|
const tmpPath = fullPath + ".tmp";
|
||||||
|
await fs.writeFile(tmpPath, content, encoding);
|
||||||
|
const fd = await fs.open(tmpPath, "r");
|
||||||
|
await fd.datasync();
|
||||||
|
await fd.close();
|
||||||
|
await fs.rename(tmpPath, fullPath);
|
||||||
|
}
|
||||||
|
|
||||||
private async walkDirectory(
|
private async walkDirectory(
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
files: RelativePath[]
|
files: RelativePath[]
|
||||||
|
|
@ -179,28 +193,9 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
||||||
await this.walkDirectory(entryRelativePath, files);
|
await this.walkDirectory(entryRelativePath, files);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
// Always return forward slashes
|
// Always return forward slashes
|
||||||
files.push(this.toUnixPath(entryRelativePath));
|
files.push(toUnixPath(entryRelativePath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a forward-slash path to native platform path separators
|
|
||||||
*/
|
|
||||||
private toNativePath(relativePath: string): string {
|
|
||||||
if (path.sep === "\\") {
|
|
||||||
return relativePath.replace(/\//g, "\\");
|
|
||||||
}
|
|
||||||
return relativePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a native platform path to forward slashes
|
|
||||||
*/
|
|
||||||
private toUnixPath(nativePath: string): string {
|
|
||||||
if (path.sep === "\\") {
|
|
||||||
return nativePath.replace(/\\/g, "/");
|
|
||||||
}
|
|
||||||
return nativePath;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
frontend/local-client-cli/src/path-utils.test.ts
Normal file
65
frontend/local-client-cli/src/path-utils.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { test } from "node:test";
|
||||||
|
import * as assert from "node:assert/strict";
|
||||||
|
import { compileGlobPattern, toUnixPath } from "./path-utils";
|
||||||
|
|
||||||
|
function matches(path: string, pattern: string): boolean {
|
||||||
|
return compileGlobPattern(pattern).test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("compileGlobPattern - exact match", () => {
|
||||||
|
assert.equal(matches(".DS_Store", ".DS_Store"), true);
|
||||||
|
assert.equal(matches("other", ".DS_Store"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compileGlobPattern - dir/** matches directory and contents", () => {
|
||||||
|
assert.equal(matches(".git", ".git/**"), true);
|
||||||
|
assert.equal(matches(".git/config", ".git/**"), true);
|
||||||
|
assert.equal(matches(".git/refs/heads/main", ".git/**"), true);
|
||||||
|
assert.equal(matches(".gitignore", ".git/**"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compileGlobPattern - * matches within a single segment", () => {
|
||||||
|
assert.equal(matches("foo.tmp", "*.tmp"), true);
|
||||||
|
assert.equal(matches("bar.tmp", "*.tmp"), true);
|
||||||
|
assert.equal(matches("foo.md", "*.tmp"), false);
|
||||||
|
// * does NOT cross path separators
|
||||||
|
assert.equal(matches("dir/foo.tmp", "*.tmp"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compileGlobPattern - **/*.ext matches at any depth", () => {
|
||||||
|
assert.equal(matches("foo.tmp", "**/*.tmp"), true);
|
||||||
|
assert.equal(matches("dir/foo.tmp", "**/*.tmp"), true);
|
||||||
|
assert.equal(matches("a/b/c/foo.tmp", "**/*.tmp"), true);
|
||||||
|
assert.equal(matches("foo.md", "**/*.tmp"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compileGlobPattern - ? matches single character", () => {
|
||||||
|
assert.equal(matches("a.md", "?.md"), true);
|
||||||
|
assert.equal(matches("ab.md", "?.md"), false);
|
||||||
|
assert.equal(matches(".md", "?.md"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compileGlobPattern - dots are escaped", () => {
|
||||||
|
assert.equal(matches(".DS_Store", ".DS_Store"), true);
|
||||||
|
assert.equal(matches("xDS_Store", ".DS_Store"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compileGlobPattern - node_modules/** matches directory tree", () => {
|
||||||
|
assert.equal(matches("node_modules", "node_modules/**"), true);
|
||||||
|
assert.equal(matches("node_modules/foo", "node_modules/**"), true);
|
||||||
|
assert.equal(
|
||||||
|
matches("node_modules/foo/bar/baz.js", "node_modules/**"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(matches("not_node_modules", "node_modules/**"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compileGlobPattern - **/ prefix matches zero or more segments", () => {
|
||||||
|
assert.equal(matches("test.log", "**/test.log"), true);
|
||||||
|
assert.equal(matches("dir/test.log", "**/test.log"), true);
|
||||||
|
assert.equal(matches("a/b/test.log", "**/test.log"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toUnixPath - forward slashes unchanged", () => {
|
||||||
|
assert.equal(toUnixPath("foo/bar/baz"), "foo/bar/baz");
|
||||||
|
});
|
||||||
74
frontend/local-client-cli/src/path-utils.ts
Normal file
74
frontend/local-client-cli/src/path-utils.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a native platform path to forward slashes.
|
||||||
|
* On non-Windows platforms this is a no-op.
|
||||||
|
*/
|
||||||
|
export function toUnixPath(nativePath: string): string {
|
||||||
|
if (path.sep === "\\") {
|
||||||
|
return nativePath.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
return nativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a forward-slash path to native platform path separators.
|
||||||
|
* On non-Windows platforms this is a no-op.
|
||||||
|
*/
|
||||||
|
export function toNativePath(forwardSlashPath: string): string {
|
||||||
|
if (path.sep === "\\") {
|
||||||
|
return forwardSlashPath.replace(/\//g, "\\");
|
||||||
|
}
|
||||||
|
return forwardSlashPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(str: string): string {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile a glob pattern into a RegExp for repeated matching.
|
||||||
|
* Supports:
|
||||||
|
* - `*` matches any characters within a single path segment
|
||||||
|
* - `**` matches zero or more path segments
|
||||||
|
* - `?` matches a single character (not `/`)
|
||||||
|
* - `dir/**` matches the directory itself and all its contents
|
||||||
|
* - combined with `*.ext` matches files with the extension at any depth
|
||||||
|
*/
|
||||||
|
export function compileGlobPattern(pattern: string): RegExp {
|
||||||
|
// Trailing /** matches the directory itself and all its contents
|
||||||
|
if (pattern.endsWith("/**")) {
|
||||||
|
const prefix = escapeRegex(pattern.slice(0, -3));
|
||||||
|
return new RegExp(`^${prefix}(/.*)?$`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = "^";
|
||||||
|
let i = 0;
|
||||||
|
while (i < pattern.length) {
|
||||||
|
const c = pattern[i];
|
||||||
|
if (c === "*" && pattern[i + 1] === "*") {
|
||||||
|
if (pattern[i + 2] === "/") {
|
||||||
|
// **/ matches zero or more directory segments
|
||||||
|
result += "(?:.+/)?";
|
||||||
|
i += 3;
|
||||||
|
} else {
|
||||||
|
result += ".*";
|
||||||
|
i += 2;
|
||||||
|
}
|
||||||
|
} else if (c === "*") {
|
||||||
|
result += "[^/]*";
|
||||||
|
i++;
|
||||||
|
} else if (c === "?") {
|
||||||
|
result += "[^/]";
|
||||||
|
i++;
|
||||||
|
} else if (".+^${}()|[]\\".includes(c)) {
|
||||||
|
result += "\\" + c;
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
result += c;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result += "$";
|
||||||
|
return new RegExp(result);
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,5 @@
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"sourceMap": true
|
"sourceMap": true
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": ["dist"]
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,32 @@ const path = require("path");
|
||||||
const webpack = require("webpack");
|
const webpack = require("webpack");
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: {
|
entry: {
|
||||||
cli: "./src/cli.ts",
|
cli: "./src/cli.ts",
|
||||||
healthcheck: "./src/healthcheck.ts"
|
healthcheck: "./src/healthcheck.ts"
|
||||||
},
|
},
|
||||||
target: "node",
|
target: "node",
|
||||||
mode: "production",
|
mode: "production",
|
||||||
optimization: {
|
optimization: {
|
||||||
minimize: false
|
minimize: false
|
||||||
},
|
},
|
||||||
module: {
|
module: {
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
test: /\.ts$/,
|
test: /\.ts$/,
|
||||||
use: "ts-loader"
|
use: "ts-loader"
|
||||||
}
|
}
|
||||||
]
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
extensions: [".ts", ".js"]
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
globalObject: "this",
|
|
||||||
filename: "[name].js",
|
|
||||||
path: path.resolve(__dirname, "dist")
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
|
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: [".ts", ".js"]
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
globalObject: "this",
|
||||||
|
filename: "[name].js",
|
||||||
|
path: path.resolve(__dirname, "dist")
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definiti
|
||||||
**Note:** The Obsidian API is still in early alpha and is subject to change at any time!
|
**Note:** The Obsidian API is still in early alpha and is subject to change at any time!
|
||||||
|
|
||||||
This sample plugin demonstrates some of the basic functionality the plugin API can do.
|
This sample plugin demonstrates some of the basic functionality the plugin API can do.
|
||||||
|
|
||||||
- Adds a ribbon icon, which shows a Notice when clicked.
|
- Adds a ribbon icon, which shows a Notice when clicked.
|
||||||
- Adds a command "Open Sample Modal" which opens a Modal.
|
- Adds a command "Open Sample Modal" which opens a Modal.
|
||||||
- Adds a plugin setting tab to the settings page.
|
- Adds a plugin setting tab to the settings page.
|
||||||
|
|
@ -57,31 +58,6 @@ Quick starting guide for new plugin devs:
|
||||||
|
|
||||||
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
|
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
|
||||||
|
|
||||||
|
|
||||||
## Funding URL
|
|
||||||
|
|
||||||
You can include funding URLs where people who use your plugin can financially support it.
|
|
||||||
|
|
||||||
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"fundingUrl": "https://buymeacoffee.com"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If you have multiple URLs, you can also do:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"fundingUrl": {
|
|
||||||
"Buy Me a Coffee": "https://buymeacoffee.com",
|
|
||||||
"GitHub Sponsor": "https://github.com/sponsors",
|
|
||||||
"Patreon": "https://www.patreon.com/"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Documentation
|
## API Documentation
|
||||||
|
|
||||||
See https://github.com/obsidianmd/obsidian-api
|
See https://github.com/obsidianmd/obsidian-api
|
||||||
|
|
|
||||||
|
|
@ -13,25 +13,25 @@
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.8.1",
|
"@types/node": "^25.0.2",
|
||||||
"css-loader": "^7.1.2",
|
"css-loader": "^7.1.2",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"file-loader": "^6.2.0",
|
"file-loader": "^6.2.0",
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.2",
|
||||||
"mini-css-extract-plugin": "^2.9.2",
|
"mini-css-extract-plugin": "^2.9.4",
|
||||||
"obsidian": "1.10.2",
|
"obsidian": "1.11.0",
|
||||||
"reconcile-text": "^0.8.0",
|
"reconcile-text": "^0.11.0",
|
||||||
"resolve-url-loader": "^5.0.0",
|
"resolve-url-loader": "^5.0.0",
|
||||||
"sass": "^1.91.0",
|
"sass": "^1.96.0",
|
||||||
"sass-loader": "^16.0.6",
|
"sass-loader": "^16.0.6",
|
||||||
"sync-client": "file:../sync-client",
|
"sync-client": "file:../sync-client",
|
||||||
"terser-webpack-plugin": "^5.3.14",
|
"terser-webpack-plugin": "^5.3.16",
|
||||||
"ts-loader": "^9.5.2",
|
"ts-loader": "^9.5.4",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.9.3",
|
||||||
"url": "^0.11.4",
|
"url": "^0.11.4",
|
||||||
"webpack": "^5.99.9",
|
"webpack": "^5.103.0",
|
||||||
"webpack-cli": "^6.0.1"
|
"webpack-cli": "^6.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -135,14 +135,14 @@ export default class VaultLinkPlugin extends Plugin {
|
||||||
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
|
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
|
||||||
...(IS_DEBUG_BUILD
|
...(IS_DEBUG_BUILD
|
||||||
? {
|
? {
|
||||||
fetch: debugging.slowFetchFactory(1),
|
fetch: debugging.slowFetchFactory(1),
|
||||||
webSocket: debugging.slowWebSocketFactory(1, new Logger())
|
webSocket: debugging.slowWebSocketFactory(1, new Logger())
|
||||||
}
|
}
|
||||||
: {})
|
: {})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (IS_DEBUG_BUILD) {
|
if (IS_DEBUG_BUILD) {
|
||||||
debugging.logToConsole(client);
|
debugging.logToConsole(client.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
return client;
|
return client;
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,8 @@ export class RemoteCursorsPluginValue implements PluginValue {
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
edited
|
edited,
|
||||||
|
"Markdown"
|
||||||
);
|
);
|
||||||
|
|
||||||
reconciled.cursors.forEach(({ id, position }) => {
|
reconciled.cursors.forEach(({ id, position }) => {
|
||||||
|
|
|
||||||
|
|
@ -266,9 +266,8 @@ export class SyncSettingsTab extends PluginSettingTab {
|
||||||
|
|
||||||
new Notice("Checking connection to the server...");
|
new Notice("Checking connection to the server...");
|
||||||
new Notice(
|
new Notice(
|
||||||
(
|
(await this.syncClient.checkConnection())
|
||||||
await this.syncClient.checkConnection()
|
.serverMessage
|
||||||
).serverMessage
|
|
||||||
);
|
);
|
||||||
await this.statusDescription.updateConnectionState();
|
await this.statusDescription.updateConnectionState();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -351,22 +350,6 @@ export class SyncSettingsTab extends PluginSettingTab {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName("Sync concurrency")
|
|
||||||
.setDesc(
|
|
||||||
"How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1."
|
|
||||||
)
|
|
||||||
.addSlider((text) =>
|
|
||||||
text
|
|
||||||
.setLimits(1, 16, 1)
|
|
||||||
.setDynamicTooltip()
|
|
||||||
.setInstant(false)
|
|
||||||
.setValue(this.syncClient.getSettings().syncConcurrency)
|
|
||||||
.onChange(async (value) =>
|
|
||||||
this.syncClient.setSetting("syncConcurrency", value)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Maximum file size to be uploaded (MB)")
|
.setName("Maximum file size to be uploaded (MB)")
|
||||||
.setDesc(
|
.setDesc(
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,7 @@
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"lib": [
|
"lib": ["DOM", "ES2024"]
|
||||||
"DOM",
|
|
||||||
"ES2024"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": ["./dist"]
|
||||||
"./dist"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ module.exports = (env, argv) => ({
|
||||||
const source = path.resolve(__dirname, "dist");
|
const source = path.resolve(__dirname, "dist");
|
||||||
const destinations = [
|
const destinations = [
|
||||||
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
||||||
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
|
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link"
|
||||||
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
|
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
|
||||||
];
|
];
|
||||||
destinations.forEach((destination) => {
|
destinations.forEach((destination) => {
|
||||||
|
|
|
||||||
5107
frontend/package-lock.json
generated
5107
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,28 +5,41 @@
|
||||||
"sync-client",
|
"sync-client",
|
||||||
"obsidian-plugin",
|
"obsidian-plugin",
|
||||||
"test-client",
|
"test-client",
|
||||||
"local-client-cli"
|
"deterministic-tests",
|
||||||
|
"local-client-cli",
|
||||||
|
"history-ui"
|
||||||
],
|
],
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"tabWidth": 4,
|
"tabWidth": 4,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"endOfLine": "lf"
|
"endOfLine": "lf",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"*.yml",
|
||||||
|
"*.yaml",
|
||||||
|
"*.md"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build --workspaces",
|
"build": "npm run build --workspaces",
|
||||||
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
|
||||||
"test": "npm run test --workspaces",
|
"test": "npm run test --workspaces",
|
||||||
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"",
|
"lint": "eslint --fix sync-client obsidian-plugin test-client deterministic-tests local-client-cli && prettier --write \"**/*.ts\"",
|
||||||
"update": "ncu -u -ws"
|
"update": "ncu -u"
|
||||||
},
|
},
|
||||||
|
The prettier configuration override for The prettier configuration override for `*.md` files uses 2-space indentation, but the command in update-api-types.sh and other scripts formats all files including markdown. This could cause inconsistent formatting in code blocks within markdown files that contain other languages. Consider excluding code blocks from this rule or being more specific about which markdown content gets 2-space indentation.
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"eclint": "^2.8.1",
|
"eslint": "9.39.2",
|
||||||
"eslint": "9.38.0",
|
"eslint-plugin-unused-imports": "^4.3.0",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"npm-check-updates": "^19.2.0",
|
||||||
"npm-check-updates": "^19.1.1",
|
"prettier": "^3.7.4",
|
||||||
"prettier": "^3.6.2",
|
"typescript-eslint": "8.49.0"
|
||||||
"typescript-eslint": "8.41.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
197
frontend/sync-client/ARCHITECTURE.md
Normal file
197
frontend/sync-client/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
# Sync Client Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The sync client synchronizes Obsidian vault files between clients via a central server. It handles offline edits, concurrent multi-client changes, crash recovery, and real-time updates via WebSocket.
|
||||||
|
|
||||||
|
## Architecture Layers
|
||||||
|
|
||||||
|
```
|
||||||
|
SyncClient (public API — unchanged)
|
||||||
|
│
|
||||||
|
├── Syncer (event router + reconciliation orchestrator)
|
||||||
|
│ │
|
||||||
|
│ ├── SyncEventQueue (per-document coalescing FIFO)
|
||||||
|
│ │ │
|
||||||
|
│ │ └── executor callback → sync-actions functions
|
||||||
|
│ │
|
||||||
|
│ └── VirtualFilesystem (document identity + state tracking)
|
||||||
|
│
|
||||||
|
├── WebSocketManager (connection, message serialization)
|
||||||
|
├── FileOperations (filesystem abstraction, 3-way merge on write)
|
||||||
|
├── SyncService (HTTP client for server REST API)
|
||||||
|
├── CursorTracker (collaborative cursor positions)
|
||||||
|
└── ContentCache (LRU cache for diff computation)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Design Decisions
|
||||||
|
|
||||||
|
### 1. Sequential Processing
|
||||||
|
|
||||||
|
All sync operations run one at a time. No concurrent sync operations, no locks, no deadlock prevention, no generation counters. The server uses SQLite which serializes writes anyway, so client-side parallelism provided no real benefit while creating enormous complexity.
|
||||||
|
|
||||||
|
The only concurrency that remains is between the sync queue and the WebSocket message handler, but both funnel events into the same sequential queue.
|
||||||
|
|
||||||
|
### 2. Virtual Filesystem (VFS)
|
||||||
|
|
||||||
|
Replaces the old `Database` class. Documents have explicit states as a discriminated union:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type VirtualDocument =
|
||||||
|
| PendingDocument // Created locally, server doesn't know yet
|
||||||
|
| TrackedDocument // Synced with server
|
||||||
|
| DeletedLocallyDocument // Deleted locally, server not yet notified
|
||||||
|
```
|
||||||
|
|
||||||
|
Three internal indexes replace the old flat array + `parallelVersion` system:
|
||||||
|
- `pathIndex` — at most one live document per path
|
||||||
|
- `documentIdIndex` — all documents with a server-assigned ID
|
||||||
|
- `idempotencyKeyIndex` — pending documents only
|
||||||
|
|
||||||
|
No more inferring state from `metadata === undefined` + `isDeleted` + `parentVersionId === 0`. The state field is the discriminator.
|
||||||
|
|
||||||
|
### 3. Per-Document Event Coalescing
|
||||||
|
|
||||||
|
Events from file watchers and WebSocket broadcasts are grouped by document identity and coalesced:
|
||||||
|
- 10 rapid edits → 1 sync operation (content read at execution time)
|
||||||
|
- create then delete → noop (file never reached the server)
|
||||||
|
- move A→B then B→C → move A→C
|
||||||
|
|
||||||
|
This replaces the old opaque-closure FIFO where every event was independent.
|
||||||
|
|
||||||
|
### 4. Server Protocol Unchanged
|
||||||
|
|
||||||
|
The server still does 3-way merging via `reconcile-text`. Response types (`FastForwardUpdate`, `MergingUpdate`) are unchanged. The client content cache remains for diff computation (needed for mobile bandwidth). Idempotency keys remain for crash-safe creates. No server changes were made.
|
||||||
|
|
||||||
|
## Module Descriptions
|
||||||
|
|
||||||
|
### `persistence/vfs.ts` — Virtual Filesystem
|
||||||
|
|
||||||
|
Tracks document identity across creates, moves, deletes. Provides:
|
||||||
|
- State transitions: `createPending()`, `confirmCreate()`, `assignDocumentId()`, `updateTracked()`, `deleteLocally()`, `confirmDelete()`
|
||||||
|
- Queries: `getByPath()`, `getByDocumentId()`, `getByIdempotencyKey()`
|
||||||
|
- Disk reconciliation: `reconcileWithDisk()` returns a pure result comparing VFS state against filesystem
|
||||||
|
- Persistence: serializes to `StoredDatabase` format (backward compatible)
|
||||||
|
|
||||||
|
### `sync-operations/sync-events.ts` — Event Types + Coalescing
|
||||||
|
|
||||||
|
Pure functions with no side effects. Defines `SyncEvent` (6 types from file watchers and WebSocket) and `CoalescedAction` (8 possible merged actions). The `coalesce()` function implements a 48-entry transition table.
|
||||||
|
|
||||||
|
### `sync-operations/sync-event-queue.ts` — Event Queue
|
||||||
|
|
||||||
|
Per-document coalescing FIFO. Maps each event to a document key (documentId for tracked docs, `path:<relativePath>` for pending docs). Processes one document at a time via an injected executor. Supports key migration when pending docs receive a documentId.
|
||||||
|
|
||||||
|
On reset (WebSocket disconnect): remote events are cleared (server replays on reconnect), local events are preserved (unsynced user actions).
|
||||||
|
|
||||||
|
### `sync-operations/sync-actions.ts` — Sync Action Implementations
|
||||||
|
|
||||||
|
Extracted from the old `unrestricted-syncer.ts`. Each function takes explicit dependencies (`SyncDeps`) and a VFS document:
|
||||||
|
|
||||||
|
- `executeSyncCreate()` — POST to server with idempotencyKey, handle response
|
||||||
|
- `executeSyncUpdate()` — compute diff from cache, PUT to server
|
||||||
|
- `executeSyncDelete()` — DELETE on server, confirm in VFS
|
||||||
|
- `executeRemoteUpdate()` — download content, write to disk, update VFS
|
||||||
|
- `applyServerResponse()` — handle MergingUpdate/FastForwardUpdate, path changes, idempotent returns
|
||||||
|
|
||||||
|
### `sync-operations/syncer.ts` — Orchestrator
|
||||||
|
|
||||||
|
Thin layer that:
|
||||||
|
- Converts file change events → `SyncEvent` objects → enqueue
|
||||||
|
- Converts WebSocket broadcasts → `SyncEvent` objects → enqueue
|
||||||
|
- Sets up the executor that dispatches `CoalescedAction` → sync-actions functions
|
||||||
|
- Runs offline reconciliation: resolve idempotency keys → scan filesystem → enqueue results
|
||||||
|
- Manages the `scheduleSyncForOfflineChanges` lifecycle
|
||||||
|
|
||||||
|
## Offline Reconciliation Algorithm
|
||||||
|
|
||||||
|
Runs on startup and WebSocket reconnect:
|
||||||
|
|
||||||
|
1. **Resolve idempotency keys** — call server for pending creates whose responses were lost
|
||||||
|
2. **Clean up orphans** — remove pending docs whose files no longer exist
|
||||||
|
3. **Scan filesystem** — `vfs.reconcileWithDisk()` compares VFS state vs actual files
|
||||||
|
4. **Apply moves** — update VFS for detected file moves (content hash matching)
|
||||||
|
5. **Enqueue events** in order:
|
||||||
|
- Interrupted deletes (VFS says deleted-locally, file gone, server not notified)
|
||||||
|
- Moves (detected via hash matching)
|
||||||
|
- Updates (file content changed)
|
||||||
|
- Creates (new files with no VFS entry)
|
||||||
|
- Delete candidates (VFS entry but file missing, not matched as a move)
|
||||||
|
|
||||||
|
Creates run before delete candidates so the server can merge creates with existing documents (preserving documentIds).
|
||||||
|
|
||||||
|
## Document Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
[File created locally]
|
||||||
|
→ VFS: createPending(path) → PendingDocument
|
||||||
|
→ Queue: enqueue local-create
|
||||||
|
→ Action: POST /documents with idempotencyKey
|
||||||
|
→ Server: returns FastForwardUpdate or MergingUpdate
|
||||||
|
→ VFS: confirmCreate() → TrackedDocument
|
||||||
|
|
||||||
|
[File edited locally]
|
||||||
|
→ Queue: enqueue local-update
|
||||||
|
→ Action: compute diff, PUT /documents/:id/text
|
||||||
|
→ Server: returns FastForwardUpdate or MergingUpdate
|
||||||
|
→ VFS: updateTracked()
|
||||||
|
|
||||||
|
[File deleted locally]
|
||||||
|
→ VFS: deleteLocally() → DeletedLocallyDocument (or removed if pending)
|
||||||
|
→ Queue: enqueue local-delete
|
||||||
|
→ Action: DELETE /documents/:id
|
||||||
|
→ VFS: confirmDelete() → removed
|
||||||
|
|
||||||
|
[Remote update via WebSocket]
|
||||||
|
→ Queue: enqueue remote-update
|
||||||
|
→ Action: fetch content, write to disk
|
||||||
|
→ VFS: updateTracked()
|
||||||
|
|
||||||
|
[Crash during create → restart]
|
||||||
|
→ VFS loads PendingDocument from disk (idempotencyKey preserved)
|
||||||
|
→ resolveIdempotencyKeys() maps key → documentId
|
||||||
|
→ VFS: assignDocumentId() → TrackedDocument with serverVersion=0
|
||||||
|
→ Queue: enqueue local-create (retry)
|
||||||
|
→ Server: returns existing document (idempotent)
|
||||||
|
→ VFS: updateTracked() with real serverVersion
|
||||||
|
```
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
1. **All state mutations go through the sequential queue.** No document state can change while a sync operation is running. File-change handlers and WebSocket handlers only enqueue events.
|
||||||
|
|
||||||
|
2. **Content cache stores server content after merges.** The cache is used for diff computation: `diff(cached_server_content, new_local_content)`. The server applies diffs against its content at `parentVersionId`.
|
||||||
|
|
||||||
|
3. **Idempotency keys survive crashes.** VFS persists pending documents with their keys. On restart, `resolveIdempotencyKeys` maps keys to documentIds. The key is preserved on TrackedDocument when `serverVersion === 0` so retry creates remain idempotent.
|
||||||
|
|
||||||
|
4. **Write file before updating metadata.** If the write fails, metadata still points to the old version. On recovery, the stale `serverVersion` triggers a re-fetch from server.
|
||||||
|
|
||||||
|
5. **Local events survive reset.** When the WebSocket disconnects, remote events are cleared (server replays on reconnect) but local events are preserved in the queue as unsynced user actions.
|
||||||
|
|
||||||
|
6. **Creates run before delete candidates** in the reconciliation ordering. A create may adopt a "deleted" document's identity via server-side merge.
|
||||||
|
|
||||||
|
## What Was Removed
|
||||||
|
|
||||||
|
- **PQueue** — configurable concurrency queue (replaced by sequential event queue)
|
||||||
|
- **Locks** — per-document multi-key locks with alphabetical ordering
|
||||||
|
- **Generation counters** — `resetGeneration` for stale operation detection
|
||||||
|
- **`containsDocument` guards** — 11 guards after async operations for concurrent-delete protection
|
||||||
|
- **`parentVersionIdForUpdate` snapshots** — mutable reference protection
|
||||||
|
- **`parallelVersion`** — collision tracking for multiple docs at same path
|
||||||
|
- **`UnrestrictedSyncer`** — 1,169-line class with nested if/else (replaced by sync-actions.ts with explicit dispatch)
|
||||||
|
- **`Database` class usage** — replaced by VFS everywhere (class still exists for type exports)
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```
|
||||||
|
persistence/
|
||||||
|
vfs.ts 779 lines — Virtual Filesystem
|
||||||
|
database.ts 535 lines — Type definitions only (StoredDatabase, RelativePath, etc.)
|
||||||
|
|
||||||
|
sync-operations/
|
||||||
|
syncer.ts 615 lines — Orchestrator
|
||||||
|
sync-actions.ts 1229 lines — Action implementations
|
||||||
|
sync-event-queue.ts 242 lines — Per-document coalescing queue
|
||||||
|
sync-events.ts 297 lines — Event types + coalescing logic
|
||||||
|
unrestricted-syncer.ts 1169 lines — DEAD CODE (not imported, to be deleted)
|
||||||
|
cursor-tracker.ts 273 lines — Cursor position tracking
|
||||||
|
```
|
||||||
|
|
@ -13,20 +13,22 @@
|
||||||
"test": "tsx --test 'src/**/*.test.ts'"
|
"test": "tsx --test 'src/**/*.test.ts'"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@sentry/browser": "^10.30.0",
|
||||||
|
"@types/murmurhash3js-revisited": "^3.0.3",
|
||||||
|
"@types/node": "^25.0.2",
|
||||||
"byte-base64": "^1.1.0",
|
"byte-base64": "^1.1.0",
|
||||||
"minimatch": "^10.0.1",
|
"minimatch": "^10.1.1",
|
||||||
"p-queue": "^8.1.0",
|
"p-queue": "^9.0.1",
|
||||||
"reconcile-text": "^0.8.0",
|
"reconcile-text": "^0.11.0",
|
||||||
"uuid": "^13.0.0",
|
"ts-loader": "^9.5.4",
|
||||||
"@types/node": "^24.8.1",
|
|
||||||
"ts-loader": "^9.5.2",
|
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.9.3",
|
||||||
"webpack": "^5.99.9",
|
"webpack": "^5.103.0",
|
||||||
"webpack-cli": "^6.0.1",
|
"webpack-cli": "^6.0.1",
|
||||||
"webpack-merge": "^6.0.1",
|
"webpack-merge": "^6.0.1"
|
||||||
"@sentry/browser": "^10.8.0",
|
},
|
||||||
"ws": "^8.18.3"
|
"dependencies": {
|
||||||
|
"murmurhash3js-revisited": "^3.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,8 @@ export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60;
|
||||||
export const DIFF_CACHE_SIZE_MB = 2;
|
export const DIFF_CACHE_SIZE_MB = 2;
|
||||||
export const MAX_LOG_MESSAGE_COUNT = 100000;
|
export const MAX_LOG_MESSAGE_COUNT = 100000;
|
||||||
export const MAX_HISTORY_ENTRY_COUNT = 5000;
|
export const MAX_HISTORY_ENTRY_COUNT = 5000;
|
||||||
export const SUPPORTED_API_VERSION = 2;
|
export const SUPPORTED_API_VERSION = 3;
|
||||||
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10;
|
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS = 10;
|
||||||
|
export const WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS = 10;
|
||||||
|
export const WEBSOCKET_HEARTBEAT_INTERVAL_MS = 30_000;
|
||||||
|
export const WEBSOCKET_HEARTBEAT_TIMEOUT_MS = 90_000;
|
||||||
|
|
|
||||||
8
frontend/sync-client/src/errors/http-client-error.ts
Normal file
8
frontend/sync-client/src/errors/http-client-error.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export class HttpClientError extends Error {
|
||||||
|
public readonly status: number;
|
||||||
|
public constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "HttpClientError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import type {
|
import type { RelativePath } from "../persistence/database";
|
||||||
Database,
|
import type { VirtualFilesystem } from "../persistence/vfs";
|
||||||
DocumentRecord,
|
|
||||||
RelativePath
|
|
||||||
} from "../persistence/database";
|
|
||||||
import { FileOperations } from "./file-operations";
|
import { FileOperations } from "./file-operations";
|
||||||
import { Logger } from "../tracing/logger";
|
import { Logger } from "../tracing/logger";
|
||||||
import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
|
import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
|
||||||
|
|
@ -21,17 +18,14 @@ class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockDatabase implements Partial<Database> {
|
class MockVfs implements Partial<VirtualFilesystem> {
|
||||||
public getLatestDocumentByRelativePath(
|
public getByPath(_path: string): undefined {
|
||||||
_find: RelativePath
|
|
||||||
): DocumentRecord | undefined {
|
|
||||||
// no-op
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public move(
|
public move(
|
||||||
_oldRelativePath: RelativePath,
|
_oldPath: string,
|
||||||
_newRelativePath: RelativePath
|
_newPath: string
|
||||||
): void {
|
): void {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +83,7 @@ describe("File operations", () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockVfs() as VirtualFilesystem, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
@ -119,7 +113,7 @@ describe("File operations", () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockVfs() as VirtualFilesystem, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
@ -159,7 +153,7 @@ describe("File operations", () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockVfs() as VirtualFilesystem, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
@ -178,7 +172,7 @@ describe("File operations", () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockVfs() as VirtualFilesystem, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
@ -207,7 +201,7 @@ describe("File operations", () => {
|
||||||
const fileSystemOperations = new FakeFileSystemOperations();
|
const fileSystemOperations = new FakeFileSystemOperations();
|
||||||
const fileOperations = new FileOperations(
|
const fileOperations = new FileOperations(
|
||||||
new Logger(),
|
new Logger(),
|
||||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockVfs() as VirtualFilesystem, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
fileSystemOperations,
|
fileSystemOperations,
|
||||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
import type { FileSystemOperations } from "./filesystem-operations";
|
import type { FileSystemOperations } from "./filesystem-operations";
|
||||||
import type { Database, RelativePath } from "../persistence/database";
|
import type { RelativePath } from "../persistence/database";
|
||||||
|
import type { VirtualFilesystem } from "../persistence/vfs";
|
||||||
import { SafeFileSystemOperations } from "./safe-filesystem-operations";
|
import { SafeFileSystemOperations } from "./safe-filesystem-operations";
|
||||||
import type { TextWithCursors } from "reconcile-text";
|
import type { TextWithCursors } from "reconcile-text";
|
||||||
import { reconcile } from "reconcile-text";
|
import { reconcile } from "reconcile-text";
|
||||||
import { isFileTypeMergable } from "../utils/is-file-type-mergable";
|
import { isFileTypeMergable } from "../utils/is-file-type-mergable";
|
||||||
import { isBinary } from "../utils/is-binary";
|
import { isBinary } from "../utils/is-binary";
|
||||||
|
import { decodeText, normalizeToUtf8 } from "../utils/decode-text";
|
||||||
import type { ServerConfig } from "../services/server-config";
|
import type { ServerConfig } from "../services/server-config";
|
||||||
|
import { validateRelativePath } from "../utils/validate-relative-path";
|
||||||
|
|
||||||
export class FileOperations {
|
export class FileOperations {
|
||||||
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
|
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
|
||||||
|
|
@ -14,7 +17,7 @@ export class FileOperations {
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly database: Database,
|
private readonly vfs: VirtualFilesystem,
|
||||||
fs: FileSystemOperations,
|
fs: FileSystemOperations,
|
||||||
private readonly serverConfig: ServerConfig,
|
private readonly serverConfig: ServerConfig,
|
||||||
private readonly nativeLineEndings = "\n"
|
private readonly nativeLineEndings = "\n"
|
||||||
|
|
@ -41,52 +44,125 @@ export class FileOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||||
return this.fromNativeLineEndings(await this.fs.read(path));
|
const raw = await this.fs.read(path);
|
||||||
|
return this.fromNativeLineEndings(normalizeToUtf8(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a file at the specified path.
|
* Create a file at the specified path.
|
||||||
*
|
*
|
||||||
* If a file with the same name already exists, it is moved before creating the new one.
|
* If a file with the same name already exists, it is moved before creating the new one.
|
||||||
* Parent directories are created if necessary.
|
* Parent directories are created if necessary.
|
||||||
*/
|
*/
|
||||||
public async create(
|
public async create(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
newContent: Uint8Array
|
newContent: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
validateRelativePath(path);
|
||||||
await this.ensureClearPath(path);
|
await this.ensureClearPath(path);
|
||||||
return this.fs.write(path, this.toNativeLineEndings(newContent));
|
return this.fs.write(path, this.toNativeLineEndings(newContent));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ensureClearPath(path: RelativePath): Promise<void> {
|
public async ensureClearPath(path: RelativePath): Promise<RelativePath | undefined> {
|
||||||
if (await this.fs.exists(path)) {
|
validateRelativePath(path);
|
||||||
|
// Acquire the lock on `path` first, then check existence inside the
|
||||||
|
// lock. The previous code checked exists() before locking, which
|
||||||
|
// created a TOCTOU race: two concurrent calls could both see the
|
||||||
|
// file as existing, but the second one would try to rename a file
|
||||||
|
// that was already moved by the first.
|
||||||
|
await this.fs.waitForLock(path);
|
||||||
|
try {
|
||||||
|
return await this.ensureClearPathLocked(path);
|
||||||
|
} finally {
|
||||||
|
this.fs.unlock(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal implementation of `ensureClearPath` that assumes the caller
|
||||||
|
* already holds the file-level lock on `path`. This allows callers like
|
||||||
|
* `move()` to keep the lock held across both the clear and the subsequent
|
||||||
|
* rename, closing the race window where another operation could create a
|
||||||
|
* file at `path` between the two steps.
|
||||||
|
*/
|
||||||
|
private async ensureClearPathLocked(
|
||||||
|
path: RelativePath
|
||||||
|
): Promise<RelativePath | undefined> {
|
||||||
|
if (await this.fs.exists(path, true)) {
|
||||||
const deconflictedPath = await this.deconflictPath(path);
|
const deconflictedPath = await this.deconflictPath(path);
|
||||||
try {
|
try {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
|
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
|
||||||
);
|
);
|
||||||
|
|
||||||
this.database.move(path, deconflictedPath);
|
// deconflictedPath is already locked via tryLock in
|
||||||
|
// deconflictPath(), so we pass skipLock=true to the
|
||||||
|
// rename to avoid deadlocking on the destination lock.
|
||||||
await this.fs.rename(path, deconflictedPath, true);
|
await this.fs.rename(path, deconflictedPath, true);
|
||||||
|
try {
|
||||||
|
this.vfs.move(path, deconflictedPath);
|
||||||
|
|
||||||
|
// Tell the sync system this displacement is system-initiated
|
||||||
|
// (not a user rename) by setting remoteRelativePath to the
|
||||||
|
// deconflicted path. This makes the check in
|
||||||
|
// syncLocallyUpdatedFile (remoteRelativePath === relativePath)
|
||||||
|
// pass, preventing the displacement from being uploaded as a
|
||||||
|
// rename to the server. Without this, the rename event from
|
||||||
|
// fs.rename() triggers an update with the deconflicted path,
|
||||||
|
// the server deconflicts further, and an infinite cascade
|
||||||
|
// ensues. The force:true content-match shortcut ensures that
|
||||||
|
// when the server eventually broadcasts the document's real
|
||||||
|
// path, the client just updates metadata without moving the
|
||||||
|
// file back.
|
||||||
|
const displacedDoc =
|
||||||
|
this.vfs.getByPath(deconflictedPath);
|
||||||
|
if (
|
||||||
|
displacedDoc?.state === "tracked" &&
|
||||||
|
displacedDoc.remoteRelativePath !== undefined
|
||||||
|
) {
|
||||||
|
displacedDoc.remoteRelativePath =
|
||||||
|
deconflictedPath;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// vfs.move() failed (e.g., a non-deleted document
|
||||||
|
// already exists at deconflictedPath). Revert the
|
||||||
|
// filesystem rename to keep file and VFS
|
||||||
|
// consistent. If the revert also fails, log it —
|
||||||
|
// scheduleSyncForOfflineChanges will reconcile.
|
||||||
|
this.logger.warn(
|
||||||
|
`vfs.move(${path}, ${deconflictedPath}) failed in ensureClearPath: ${e}, reverting filesystem rename`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await this.fs.rename(deconflictedPath, path, true);
|
||||||
|
} catch (revertError) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to revert filesystem rename from ${deconflictedPath} to ${path}: ${revertError}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.fs.unlock(deconflictedPath);
|
this.fs.unlock(deconflictedPath);
|
||||||
}
|
}
|
||||||
|
return deconflictedPath;
|
||||||
} else {
|
} else {
|
||||||
await this.createParentDirectories(path);
|
await this.createParentDirectories(path);
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the file at the given path.
|
* Update the file at the given path.
|
||||||
*
|
*
|
||||||
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
|
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
|
||||||
* Does not recreate the file if it no longer exists, returning an empty array instead.
|
* Does not recreate the file if it no longer exists, returning an empty array instead.
|
||||||
*/
|
*/
|
||||||
public async write(
|
public async write(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
expectedContent: Uint8Array,
|
expectedContent: Uint8Array,
|
||||||
newContent: Uint8Array
|
newContent: Uint8Array
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
validateRelativePath(path);
|
||||||
if (!(await this.fs.exists(path))) {
|
if (!(await this.fs.exists(path))) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`The caller assumed ${path} exists, but it no longer, so we wont recreate it`
|
`The caller assumed ${path} exists, but it no longer, so we wont recreate it`
|
||||||
|
|
@ -113,8 +189,10 @@ export class FileOperations {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings
|
const expectedText = (decodeText(expectedContent) ?? "").normalize(
|
||||||
const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings
|
"NFC"
|
||||||
|
); // this comes from a previous read which must only have \n line endings
|
||||||
|
const newText = (decodeText(newContent) ?? "").normalize("NFC"); // this comes from the server which stores text with \n line endings
|
||||||
|
|
||||||
await this.fs.atomicUpdateText(
|
await this.fs.atomicUpdateText(
|
||||||
path,
|
path,
|
||||||
|
|
@ -123,12 +201,29 @@ export class FileOperations {
|
||||||
`Performing a 3-way merge for ${path} with the expected content`
|
`Performing a 3-way merge for ${path} with the expected content`
|
||||||
);
|
);
|
||||||
|
|
||||||
text = text.replaceAll(this.nativeLineEndings, "\n");
|
text = text
|
||||||
const merged = reconcile(
|
.replaceAll(this.nativeLineEndings, "\n")
|
||||||
expectedText,
|
.normalize("NFC");
|
||||||
{ text, cursors },
|
|
||||||
newText
|
let merged: TextWithCursors;
|
||||||
);
|
try {
|
||||||
|
merged = reconcile(
|
||||||
|
expectedText,
|
||||||
|
{ text, cursors },
|
||||||
|
newText,
|
||||||
|
"Markdown"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// 3-way merge failed (e.g., content was fully replaced
|
||||||
|
// by another agent). Save the local content as a conflict
|
||||||
|
// file before overwriting with the server's content, so
|
||||||
|
// the user's edits are never silently lost.
|
||||||
|
this.logger.info(
|
||||||
|
`3-way merge failed for ${path}, saving local content as conflict file and using server content`
|
||||||
|
);
|
||||||
|
this.saveConflictFile(path, text);
|
||||||
|
merged = { text: newText, cursors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
const resultText = merged.text.replaceAll(
|
const resultText = merged.text.replaceAll(
|
||||||
"\n",
|
"\n",
|
||||||
|
|
@ -144,6 +239,7 @@ export class FileOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async delete(path: RelativePath): Promise<void> {
|
public async delete(path: RelativePath): Promise<void> {
|
||||||
|
validateRelativePath(path);
|
||||||
if (await this.exists(path)) {
|
if (await this.exists(path)) {
|
||||||
await this.fs.delete(path);
|
await this.fs.delete(path);
|
||||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
||||||
|
|
@ -164,14 +260,47 @@ export class FileOperations {
|
||||||
oldPath: RelativePath,
|
oldPath: RelativePath,
|
||||||
newPath: RelativePath
|
newPath: RelativePath
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
validateRelativePath(oldPath);
|
||||||
|
validateRelativePath(newPath);
|
||||||
if (oldPath === newPath) {
|
if (oldPath === newPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.ensureClearPath(newPath);
|
// Hold the newPath lock across both ensureClearPath and rename.
|
||||||
|
// Without this, another operation could create a file at newPath
|
||||||
|
// between ensureClearPath releasing the lock and rename acquiring
|
||||||
|
// it, causing the rename to silently overwrite the new file.
|
||||||
|
await this.fs.waitForLock(newPath);
|
||||||
|
try {
|
||||||
|
await this.ensureClearPathLocked(newPath);
|
||||||
|
// skipLock=true because we already hold the newPath lock.
|
||||||
|
// The oldPath lock is not needed; sync operations run
|
||||||
|
// sequentially so no concurrent operation can race on paths.
|
||||||
|
await this.fs.rename(oldPath, newPath, true);
|
||||||
|
} finally {
|
||||||
|
this.fs.unlock(newPath);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.vfs.move(oldPath, newPath);
|
||||||
|
} catch (e) {
|
||||||
|
// vfs.move() failed (e.g., a non-deleted document already
|
||||||
|
// exists at newPath). Revert the filesystem rename to keep the
|
||||||
|
// file and VFS consistent. If the revert also fails, log
|
||||||
|
// it — scheduleSyncForOfflineChanges will reconcile on the
|
||||||
|
// next cycle.
|
||||||
|
this.logger.warn(
|
||||||
|
`vfs.move(${oldPath}, ${newPath}) failed: ${e}, reverting filesystem rename`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await this.fs.rename(newPath, oldPath);
|
||||||
|
} catch (revertError) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to revert filesystem rename from ${newPath} to ${oldPath}: ${revertError}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
this.database.move(oldPath, newPath);
|
|
||||||
await this.fs.rename(oldPath, newPath);
|
|
||||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,25 +333,60 @@ export class FileOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fromNativeLineEndings(content: Uint8Array): Uint8Array {
|
private fromNativeLineEndings(content: Uint8Array): Uint8Array {
|
||||||
if (isBinary(content)) {
|
const text = decodeText(content);
|
||||||
|
if (text === undefined) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoder = new TextDecoder("utf-8");
|
const normalized = text.replaceAll(this.nativeLineEndings, "\n");
|
||||||
let text = decoder.decode(content);
|
return new TextEncoder().encode(normalized);
|
||||||
text = text.replaceAll(this.nativeLineEndings, "\n");
|
|
||||||
return new TextEncoder().encode(text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private toNativeLineEndings(content: Uint8Array): Uint8Array {
|
private toNativeLineEndings(content: Uint8Array): Uint8Array {
|
||||||
if (isBinary(content)) {
|
const text = decodeText(content);
|
||||||
|
if (text === undefined) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoder = new TextDecoder("utf-8");
|
const normalized = text.replaceAll("\n", this.nativeLineEndings);
|
||||||
let text = decoder.decode(content);
|
return new TextEncoder().encode(normalized);
|
||||||
text = text.replaceAll("\n", this.nativeLineEndings);
|
}
|
||||||
return new TextEncoder().encode(text);
|
|
||||||
|
/**
|
||||||
|
* Save the local content of a file as a conflict file when 3-way merge
|
||||||
|
* fails, so the user's edits are never silently lost. The conflict file
|
||||||
|
* is created at a deconflicted path (e.g., "file (conflict 1).md").
|
||||||
|
*
|
||||||
|
* This is fire-and-forget — errors are logged but do not prevent the
|
||||||
|
* caller from proceeding with the server's content.
|
||||||
|
*/
|
||||||
|
private saveConflictFile(
|
||||||
|
path: RelativePath,
|
||||||
|
localContent: string
|
||||||
|
): void {
|
||||||
|
const contentBytes = new TextEncoder().encode(
|
||||||
|
localContent.replaceAll("\n", this.nativeLineEndings)
|
||||||
|
);
|
||||||
|
// Fire-and-forget: we don't want a failed conflict-save to prevent
|
||||||
|
// the server content from being written.
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const conflictPath =
|
||||||
|
await this.deconflictPath(path);
|
||||||
|
try {
|
||||||
|
await this.fs.write(conflictPath, contentBytes);
|
||||||
|
this.logger.info(
|
||||||
|
`Saved local content as conflict file: ${conflictPath}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.fs.unlock(conflictPath);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to save conflict file for ${path}: ${e}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createParentDirectories(path: string): Promise<void> {
|
private async createParentDirectories(path: string): Promise<void> {
|
||||||
|
|
@ -239,12 +403,12 @@ export class FileOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found.
|
* Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found.
|
||||||
* The returned path has a lock acquired on it; it must be released by the caller when no longer needed.
|
* The returned path has a lock acquired on it; it must be released by the caller when no longer needed.
|
||||||
*
|
*
|
||||||
* @param path The starting path to deconflict
|
* @param path The starting path to deconflict
|
||||||
* @returns a non-existent path with a lock acquired on it
|
* @returns a non-existent path with a lock acquired on it
|
||||||
*/
|
*/
|
||||||
private async deconflictPath(path: RelativePath): Promise<RelativePath> {
|
private async deconflictPath(path: RelativePath): Promise<RelativePath> {
|
||||||
// eslint-disable-next-line prefer-const
|
// eslint-disable-next-line prefer-const
|
||||||
let [directory, fileName] = FileOperations.getParentDirAndFile(path);
|
let [directory, fileName] = FileOperations.getParentDirAndFile(path);
|
||||||
|
|
@ -275,10 +439,12 @@ export class FileOperations {
|
||||||
|
|
||||||
// Avoid multiple deconflictPath calls returning the same path
|
// Avoid multiple deconflictPath calls returning the same path
|
||||||
if (this.fs.tryLock(newName)) {
|
if (this.fs.tryLock(newName)) {
|
||||||
const newDocument =
|
// getByPath only returns live docs (pending/tracked), not
|
||||||
this.database.getLatestDocumentByRelativePath(newName);
|
// deleted-locally ones, so a non-undefined result means
|
||||||
|
// the path is occupied.
|
||||||
|
const existingDoc = this.vfs.getByPath(newName);
|
||||||
if (
|
if (
|
||||||
newDocument?.isDeleted === false || // the document might have been confirmed by the server at a new path but haven't yet moved there locally
|
existingDoc !== undefined || // 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))
|
(await this.fs.exists(newName, true))
|
||||||
) {
|
) {
|
||||||
this.fs.unlock(newName);
|
this.fs.unlock(newName);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { RelativePath } from "../persistence/database";
|
||||||
import type { FileSystemOperations } from "./filesystem-operations";
|
import type { FileSystemOperations } from "./filesystem-operations";
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
import { Locks } from "../utils/data-structures/locks";
|
import { Locks } from "../utils/data-structures/locks";
|
||||||
import { FileNotFoundError } from "./file-not-found-error";
|
import { FileNotFoundError } from "../errors/file-not-found-error";
|
||||||
import type { TextWithCursors } from "reconcile-text";
|
import type { TextWithCursors } from "reconcile-text";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -17,7 +17,7 @@ export class SafeFileSystemOperations implements FileSystemOperations {
|
||||||
private readonly fs: FileSystemOperations,
|
private readonly fs: FileSystemOperations,
|
||||||
private readonly logger: Logger
|
private readonly logger: Logger
|
||||||
) {
|
) {
|
||||||
this.locks = new Locks(logger);
|
this.locks = new Locks(SafeFileSystemOperations.name, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async listFilesRecursively(
|
public async listFilesRecursively(
|
||||||
|
|
@ -135,10 +135,10 @@ export class SafeFileSystemOperations implements FileSystemOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decorate an operation to ensure that the file exists before running it.
|
* Decorate an operation to ensure that the file exists before running it.
|
||||||
* If the operation fails, it will check if the file still exists and throw
|
* If the operation fails, it will check if the file still exists and throw
|
||||||
* a FileNotFoundError if it doesn't.
|
* a FileNotFoundError if it doesn't.
|
||||||
*/
|
*/
|
||||||
private async safeOperation<T>(
|
private async safeOperation<T>(
|
||||||
path: RelativePath,
|
path: RelativePath,
|
||||||
operation: () => Promise<T>,
|
operation: () => Promise<T>,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { awaitAll } from "./utils/await-all";
|
||||||
import { logToConsole } from "./utils/debugging/log-to-console";
|
import { logToConsole } from "./utils/debugging/log-to-console";
|
||||||
import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory";
|
import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory";
|
||||||
import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory";
|
import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory";
|
||||||
|
import { InMemoryFileSystem } from "./utils/debugging/in-memory-file-system";
|
||||||
import { getRandomColor } from "./utils/get-random-color";
|
import { getRandomColor } from "./utils/get-random-color";
|
||||||
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
|
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
|
||||||
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
|
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
|
||||||
|
|
@ -27,8 +28,8 @@ export type { PersistenceProvider } from "./persistence/persistence";
|
||||||
export type { CursorSpan } from "./services/types/CursorSpan";
|
export type { CursorSpan } from "./services/types/CursorSpan";
|
||||||
export type { ClientCursors } from "./services/types/ClientCursors";
|
export type { ClientCursors } from "./services/types/ClientCursors";
|
||||||
export type { NetworkConnectionStatus } from "./types/network-connection-status";
|
export type { NetworkConnectionStatus } from "./types/network-connection-status";
|
||||||
export type { ServerVersionMismatchError } from "./services/server-version-mismatch-error";
|
export type { ServerVersionMismatchError } from "./errors/server-version-mismatch-error";
|
||||||
export type { AuthenticationError } from "./services/authentication-error";
|
export type { AuthenticationError } from "./errors/authentication-error";
|
||||||
export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
|
export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
|
||||||
export { DocumentSyncStatus } from "./types/document-sync-status";
|
export { DocumentSyncStatus } from "./types/document-sync-status";
|
||||||
export { SyncClient } from "./sync-client";
|
export { SyncClient } from "./sync-client";
|
||||||
|
|
@ -37,7 +38,8 @@ export type { TextWithCursors, CursorPosition } from "reconcile-text";
|
||||||
export const debugging = {
|
export const debugging = {
|
||||||
slowFetchFactory,
|
slowFetchFactory,
|
||||||
slowWebSocketFactory,
|
slowWebSocketFactory,
|
||||||
logToConsole
|
logToConsole,
|
||||||
|
InMemoryFileSystem
|
||||||
};
|
};
|
||||||
|
|
||||||
export const utils = {
|
export const utils = {
|
||||||
|
|
|
||||||
|
|
@ -1,374 +1,24 @@
|
||||||
import type { Logger } from "../tracing/logger";
|
|
||||||
import { EMPTY_HASH } from "../utils/hash";
|
|
||||||
import { CoveredValues } from "../utils/data-structures/min-covered";
|
|
||||||
import { awaitAll } from "../utils/await-all";
|
|
||||||
import { removeFromArray } from "../utils/remove-from-array";
|
|
||||||
|
|
||||||
export type VaultUpdateId = number;
|
export type VaultUpdateId = number;
|
||||||
export type DocumentId = string;
|
export type DocumentId = string;
|
||||||
export type RelativePath = string;
|
export type RelativePath = string;
|
||||||
|
|
||||||
export interface DocumentMetadata {
|
|
||||||
parentVersionId: VaultUpdateId;
|
|
||||||
hash: string;
|
|
||||||
remoteRelativePath?: RelativePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StoredDocumentMetadata {
|
export interface StoredDocumentMetadata {
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
documentId: DocumentId;
|
documentId: DocumentId;
|
||||||
parentVersionId: VaultUpdateId;
|
parentVersionId: VaultUpdateId;
|
||||||
remoteRelativePath?: RelativePath;
|
remoteRelativePath?: RelativePath;
|
||||||
hash: string;
|
hash: string;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredPendingDocument {
|
||||||
|
relativePath: RelativePath;
|
||||||
|
idempotencyKey: string;
|
||||||
|
originalCreationPath: RelativePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredDatabase {
|
export interface StoredDatabase {
|
||||||
documents: StoredDocumentMetadata[];
|
documents: StoredDocumentMetadata[];
|
||||||
|
pendingDocuments?: StoredPendingDocument[];
|
||||||
lastSeenUpdateId: VaultUpdateId | undefined;
|
lastSeenUpdateId: VaultUpdateId | undefined;
|
||||||
hasInitialSyncCompleted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a document in the database.
|
|
||||||
*
|
|
||||||
* It is mutable and its content should always represent the latest
|
|
||||||
* state of the document on disk based on the update events we have seen.
|
|
||||||
*/
|
|
||||||
export interface DocumentRecord {
|
|
||||||
relativePath: RelativePath;
|
|
||||||
documentId: DocumentId;
|
|
||||||
metadata: DocumentMetadata | undefined;
|
|
||||||
isDeleted: boolean;
|
|
||||||
updates: Promise<unknown>[];
|
|
||||||
parallelVersion: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Database {
|
|
||||||
private documents: DocumentRecord[];
|
|
||||||
private lastSeenUpdateIds: CoveredValues;
|
|
||||||
private hasInitialSyncCompleted: boolean;
|
|
||||||
|
|
||||||
public constructor(
|
|
||||||
private readonly logger: Logger,
|
|
||||||
initialState: Partial<StoredDatabase> | undefined,
|
|
||||||
private readonly saveData: (data: StoredDatabase) => Promise<void>
|
|
||||||
) {
|
|
||||||
initialState ??= {};
|
|
||||||
|
|
||||||
this.documents =
|
|
||||||
initialState.documents?.map(
|
|
||||||
({ relativePath, documentId, ...metadata }) => ({
|
|
||||||
relativePath,
|
|
||||||
documentId,
|
|
||||||
metadata,
|
|
||||||
isDeleted: false,
|
|
||||||
updates: [],
|
|
||||||
parallelVersion: 0
|
|
||||||
})
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
this.ensureConsistency();
|
|
||||||
this.logger.debug(`Loaded ${this.documents.length} documents`);
|
|
||||||
|
|
||||||
const { lastSeenUpdateId } = initialState;
|
|
||||||
this.logger.debug(`Loaded last seen update id: ${lastSeenUpdateId}`);
|
|
||||||
this.lastSeenUpdateIds = new CoveredValues(
|
|
||||||
Math.max(0, lastSeenUpdateId ?? 0) // the first updateId will be 1 which is the first integer after -1
|
|
||||||
);
|
|
||||||
|
|
||||||
this.documents.forEach((doc) => {
|
|
||||||
this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.hasInitialSyncCompleted =
|
|
||||||
initialState.hasInitialSyncCompleted ?? false;
|
|
||||||
this.logger.debug(
|
|
||||||
`Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public get length(): number {
|
|
||||||
return this.documents.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
public get resolvedDocuments(): DocumentRecord[] {
|
|
||||||
const paths = new Map<string, DocumentRecord[]>();
|
|
||||||
this.documents
|
|
||||||
// eslint-disable-next-line no-restricted-syntax -- Type narrowing, not removing a specific item
|
|
||||||
.filter(({ metadata }) => metadata !== undefined)
|
|
||||||
.forEach((record) =>
|
|
||||||
paths.set(record.relativePath, [
|
|
||||||
record,
|
|
||||||
...(paths.get(record.relativePath) ?? [])
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
return Array.from(paths.values()).map((records) => {
|
|
||||||
records.sort(
|
|
||||||
(a, b) => b.parallelVersion - a.parallelVersion // descending
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
records.length > 1 &&
|
|
||||||
records.some((current, i) =>
|
|
||||||
i === 0
|
|
||||||
? false
|
|
||||||
: records[i - 1].parallelVersion ===
|
|
||||||
current.parallelVersion
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Multiple documents with the same parallel version and path at ${records[0].relativePath}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return records[0];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public updateDocumentMetadata(
|
|
||||||
metadata: {
|
|
||||||
parentVersionId: VaultUpdateId;
|
|
||||||
hash: string;
|
|
||||||
remoteRelativePath: RelativePath;
|
|
||||||
},
|
|
||||||
toUpdate: DocumentRecord
|
|
||||||
): void {
|
|
||||||
if (!this.documents.includes(toUpdate)) {
|
|
||||||
throw new Error("Document not found in database");
|
|
||||||
}
|
|
||||||
|
|
||||||
toUpdate.metadata = metadata;
|
|
||||||
|
|
||||||
this.saveInTheBackground();
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeDocumentPromise(promise: Promise<unknown>): void {
|
|
||||||
const entry = this.documents.find(({ updates }) =>
|
|
||||||
updates.includes(promise)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (entry === undefined) {
|
|
||||||
// This method should be idempotent and tolerant of
|
|
||||||
// stragglers calling it after the databse has been reset.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFromArray(entry.updates, promise);
|
|
||||||
// No need to save as Promises don't get serialized
|
|
||||||
}
|
|
||||||
|
|
||||||
public removeDocument(find: DocumentRecord): void {
|
|
||||||
removeFromArray(this.documents, find);
|
|
||||||
this.saveInTheBackground();
|
|
||||||
}
|
|
||||||
|
|
||||||
public getLatestDocumentByRelativePath(
|
|
||||||
find: RelativePath
|
|
||||||
): DocumentRecord | undefined {
|
|
||||||
const candidates = this.documents.filter(
|
|
||||||
({ relativePath }) => relativePath === find
|
|
||||||
);
|
|
||||||
candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending
|
|
||||||
return candidates[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getResolvedDocumentByRelativePath(
|
|
||||||
relativePath: RelativePath,
|
|
||||||
promise: Promise<unknown>
|
|
||||||
): Promise<DocumentRecord> {
|
|
||||||
const entry = this.getLatestDocumentByRelativePath(relativePath);
|
|
||||||
|
|
||||||
if (entry === undefined) {
|
|
||||||
throw new Error(
|
|
||||||
`Document not found by relative path: ${relativePath}, ${JSON.stringify(
|
|
||||||
this.documents,
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPromises = entry.updates;
|
|
||||||
entry.updates = [...currentPromises, promise];
|
|
||||||
await awaitAll(currentPromises);
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createNewPendingDocument(
|
|
||||||
documentId: DocumentId,
|
|
||||||
relativePath: RelativePath,
|
|
||||||
promise: Promise<unknown>
|
|
||||||
): DocumentRecord {
|
|
||||||
this.logger.debug(
|
|
||||||
`Creating new pending document: ${relativePath} (${documentId})`
|
|
||||||
);
|
|
||||||
const previousEntry =
|
|
||||||
this.getLatestDocumentByRelativePath(relativePath);
|
|
||||||
|
|
||||||
const entry = {
|
|
||||||
relativePath,
|
|
||||||
documentId,
|
|
||||||
metadata: undefined,
|
|
||||||
isDeleted: false,
|
|
||||||
updates: [promise],
|
|
||||||
parallelVersion:
|
|
||||||
previousEntry?.parallelVersion === undefined
|
|
||||||
? 0
|
|
||||||
: previousEntry.parallelVersion + 1
|
|
||||||
};
|
|
||||||
|
|
||||||
this.documents.push(entry);
|
|
||||||
this.saveInTheBackground();
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
public createNewEmptyDocument(
|
|
||||||
documentId: DocumentId,
|
|
||||||
parentVersionId: VaultUpdateId,
|
|
||||||
relativePath: RelativePath
|
|
||||||
): DocumentRecord {
|
|
||||||
const entry = {
|
|
||||||
relativePath,
|
|
||||||
documentId,
|
|
||||||
metadata: {
|
|
||||||
parentVersionId,
|
|
||||||
hash: EMPTY_HASH,
|
|
||||||
remoteRelativePath: relativePath
|
|
||||||
},
|
|
||||||
isDeleted: false,
|
|
||||||
updates: [],
|
|
||||||
parallelVersion: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
this.documents.push(entry);
|
|
||||||
this.saveInTheBackground();
|
|
||||||
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getDocumentByDocumentId(
|
|
||||||
find: DocumentId
|
|
||||||
): DocumentRecord | undefined {
|
|
||||||
return this.documents.find(({ documentId }) => documentId === find);
|
|
||||||
}
|
|
||||||
|
|
||||||
public move(
|
|
||||||
oldRelativePath: RelativePath,
|
|
||||||
newRelativePath: RelativePath
|
|
||||||
): void {
|
|
||||||
const oldDocument =
|
|
||||||
this.getLatestDocumentByRelativePath(oldRelativePath);
|
|
||||||
|
|
||||||
if (oldDocument === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newDocument =
|
|
||||||
this.getLatestDocumentByRelativePath(newRelativePath);
|
|
||||||
if (newDocument?.isDeleted === false) {
|
|
||||||
throw new Error(
|
|
||||||
`Document already exists at new location: ${newRelativePath}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
oldDocument.relativePath = newRelativePath;
|
|
||||||
// We're in a strange state where the target of the move has just got deleted,
|
|
||||||
// however, its metadata might already have a bunch of updates queued up for
|
|
||||||
// the document at the new location. We need to keep these updates.
|
|
||||||
oldDocument.parallelVersion =
|
|
||||||
newDocument !== undefined ? newDocument.parallelVersion + 1 : 0;
|
|
||||||
|
|
||||||
this.saveInTheBackground();
|
|
||||||
}
|
|
||||||
|
|
||||||
public delete(relativePath: RelativePath): void {
|
|
||||||
const candidate = this.getLatestDocumentByRelativePath(relativePath);
|
|
||||||
if (candidate === undefined) {
|
|
||||||
throw new Error(
|
|
||||||
`Document not found by relative path: ${relativePath}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
candidate.isDeleted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getHasInitialSyncCompleted(): boolean {
|
|
||||||
return this.hasInitialSyncCompleted;
|
|
||||||
}
|
|
||||||
|
|
||||||
public setHasInitialSyncCompleted(value: boolean): void {
|
|
||||||
this.hasInitialSyncCompleted = value;
|
|
||||||
this.saveInTheBackground();
|
|
||||||
}
|
|
||||||
|
|
||||||
public getLastSeenUpdateId(): VaultUpdateId {
|
|
||||||
return this.lastSeenUpdateIds.min;
|
|
||||||
}
|
|
||||||
|
|
||||||
public addSeenUpdateId(value: number): void {
|
|
||||||
const previousMin = this.lastSeenUpdateIds.min;
|
|
||||||
this.lastSeenUpdateIds.add(value);
|
|
||||||
if (previousMin !== this.lastSeenUpdateIds.min) {
|
|
||||||
this.saveInTheBackground();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public setLastSeenUpdateId(value: number): void {
|
|
||||||
this.lastSeenUpdateIds.min = value;
|
|
||||||
this.saveInTheBackground();
|
|
||||||
}
|
|
||||||
|
|
||||||
public reset(): void {
|
|
||||||
this.documents = [];
|
|
||||||
this.lastSeenUpdateIds = new CoveredValues(
|
|
||||||
0 // the first updateId will be 1 which is the first integer after -1
|
|
||||||
);
|
|
||||||
this.hasInitialSyncCompleted = false;
|
|
||||||
this.saveInTheBackground();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async save(): Promise<void> {
|
|
||||||
return this.saveData({
|
|
||||||
documents: this.resolvedDocuments.map(
|
|
||||||
({ relativePath, documentId, metadata }) => ({
|
|
||||||
documentId,
|
|
||||||
relativePath,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
...metadata! // `resolvedDocuments` only returns docs with metadata set
|
|
||||||
})
|
|
||||||
),
|
|
||||||
lastSeenUpdateId: this.lastSeenUpdateIds.min,
|
|
||||||
hasInitialSyncCompleted: this.hasInitialSyncCompleted
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureConsistency(): void {
|
|
||||||
const idToPath = new Map<string, string[]>();
|
|
||||||
|
|
||||||
this.resolvedDocuments.forEach(({ relativePath, documentId }) => {
|
|
||||||
idToPath.set(documentId, [
|
|
||||||
...(idToPath.get(documentId) ?? []),
|
|
||||||
relativePath
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
const duplicates = Array.from(idToPath.entries())
|
|
||||||
.filter(([_, paths]) => paths.length > 1)
|
|
||||||
.map(([id, paths]) => `${id} (${paths.join(", ")})`);
|
|
||||||
|
|
||||||
if (duplicates.length > 0) {
|
|
||||||
throw new Error(
|
|
||||||
"Document IDs are not unique, found duplicates: " +
|
|
||||||
duplicates.join("; ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveInTheBackground(): void {
|
|
||||||
this.ensureConsistency();
|
|
||||||
void this.save().catch((error: unknown) => {
|
|
||||||
this.logger.error(`Error saving data: ${error}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ export interface SyncSettings {
|
||||||
remoteUri: string;
|
remoteUri: string;
|
||||||
token: string;
|
token: string;
|
||||||
vaultName: string;
|
vaultName: string;
|
||||||
syncConcurrency: number;
|
|
||||||
isSyncEnabled: boolean;
|
isSyncEnabled: boolean;
|
||||||
maxFileSizeMB: number;
|
maxFileSizeMB: number;
|
||||||
ignorePatterns: string[];
|
ignorePatterns: string[];
|
||||||
|
|
@ -21,7 +20,6 @@ export const DEFAULT_SETTINGS: SyncSettings = {
|
||||||
remoteUri: "",
|
remoteUri: "",
|
||||||
token: "",
|
token: "",
|
||||||
vaultName: "default",
|
vaultName: "default",
|
||||||
syncConcurrency: 1,
|
|
||||||
isSyncEnabled: false,
|
isSyncEnabled: false,
|
||||||
maxFileSizeMB: 10,
|
maxFileSizeMB: 10,
|
||||||
ignorePatterns: [],
|
ignorePatterns: [],
|
||||||
|
|
@ -38,7 +36,7 @@ export class Settings {
|
||||||
>();
|
>();
|
||||||
|
|
||||||
private settings: SyncSettings;
|
private settings: SyncSettings;
|
||||||
private readonly lock: Lock = new Lock();
|
private readonly lock: Lock;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
|
|
@ -50,6 +48,8 @@ export class Settings {
|
||||||
...(initialState ?? {})
|
...(initialState ?? {})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.lock = new Lock(Settings.name, this.logger);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Loaded settings: ${JSON.stringify(this.settings, null, 2)}`
|
`Loaded settings: ${JSON.stringify(this.settings, null, 2)}`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
820
frontend/sync-client/src/persistence/vfs.ts
Normal file
820
frontend/sync-client/src/persistence/vfs.ts
Normal file
|
|
@ -0,0 +1,820 @@
|
||||||
|
import type { Logger } from "../tracing/logger";
|
||||||
|
import { EMPTY_HASH } from "../utils/hash";
|
||||||
|
import { CoveredValues } from "../utils/data-structures/min-covered";
|
||||||
|
import type {
|
||||||
|
StoredDatabase,
|
||||||
|
StoredDocumentMetadata,
|
||||||
|
StoredPendingDocument,
|
||||||
|
VaultUpdateId,
|
||||||
|
DocumentId,
|
||||||
|
RelativePath
|
||||||
|
} from "./database";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Document state types (discriminated union)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PendingDocument {
|
||||||
|
readonly state: "pending";
|
||||||
|
relativePath: string;
|
||||||
|
readonly idempotencyKey: string;
|
||||||
|
readonly originalCreationPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackedDocument {
|
||||||
|
readonly state: "tracked";
|
||||||
|
relativePath: string;
|
||||||
|
documentId: string;
|
||||||
|
serverVersion: number;
|
||||||
|
localHash: string;
|
||||||
|
remoteRelativePath: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeletedLocallyDocument {
|
||||||
|
readonly state: "deleted-locally";
|
||||||
|
relativePath: string;
|
||||||
|
readonly documentId: string;
|
||||||
|
readonly serverVersion: number;
|
||||||
|
readonly remoteRelativePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VirtualDocument =
|
||||||
|
| PendingDocument
|
||||||
|
| TrackedDocument
|
||||||
|
| DeletedLocallyDocument;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reconciliation result
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ReconciliationResult {
|
||||||
|
newFiles: string[];
|
||||||
|
modifiedFiles: { path: string; documentId: string }[];
|
||||||
|
missingFiles: VirtualDocument[];
|
||||||
|
movedFiles: { document: TrackedDocument; newPath: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VirtualFilesystem
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class VirtualFilesystem {
|
||||||
|
/** One live document per path (pending or tracked, NOT deleted-locally). */
|
||||||
|
private readonly pathIndex = new Map<string, VirtualDocument>();
|
||||||
|
|
||||||
|
/** All documents that have a documentId (tracked + deleted-locally). */
|
||||||
|
private readonly documentIdIndex = new Map<string, VirtualDocument>();
|
||||||
|
|
||||||
|
/** Pending documents by idempotency key. */
|
||||||
|
private readonly idempotencyKeyIndex = new Map<string, PendingDocument>();
|
||||||
|
|
||||||
|
private lastSeenUpdateIds: CoveredValues;
|
||||||
|
|
||||||
|
private pendingSave: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly logger: Logger,
|
||||||
|
initialState: Partial<StoredDatabase> | undefined,
|
||||||
|
private readonly saveData: (data: StoredDatabase) => Promise<void>
|
||||||
|
) {
|
||||||
|
const state: Partial<StoredDatabase> = initialState ?? {};
|
||||||
|
|
||||||
|
const validDocuments = (state.documents ?? []).filter(
|
||||||
|
(doc) =>
|
||||||
|
this.validateStoredField(doc, "relativePath", "string") &&
|
||||||
|
this.validateStoredField(doc, "documentId", "string") &&
|
||||||
|
this.validateStoredField(doc, "parentVersionId", "number")
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const stored of validDocuments) {
|
||||||
|
if (stored.isDeleted === true) {
|
||||||
|
const doc: DeletedLocallyDocument = {
|
||||||
|
state: "deleted-locally",
|
||||||
|
relativePath: stored.relativePath,
|
||||||
|
documentId: stored.documentId,
|
||||||
|
serverVersion: stored.parentVersionId,
|
||||||
|
remoteRelativePath:
|
||||||
|
stored.remoteRelativePath ?? stored.relativePath
|
||||||
|
};
|
||||||
|
// deleted-locally docs go into documentIdIndex only
|
||||||
|
this.documentIdIndex.set(doc.documentId, doc);
|
||||||
|
} else {
|
||||||
|
const doc: TrackedDocument = {
|
||||||
|
state: "tracked",
|
||||||
|
relativePath: stored.relativePath,
|
||||||
|
documentId: stored.documentId,
|
||||||
|
serverVersion: stored.parentVersionId,
|
||||||
|
localHash: stored.hash,
|
||||||
|
remoteRelativePath:
|
||||||
|
stored.remoteRelativePath ?? stored.relativePath
|
||||||
|
};
|
||||||
|
// If two stored documents have the same path, last one wins
|
||||||
|
// (matches old behavior where highest parallelVersion wins)
|
||||||
|
this.pathIndex.set(doc.relativePath, doc);
|
||||||
|
this.documentIdIndex.set(doc.documentId, doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPendingDocuments = (state.pendingDocuments ?? []).filter(
|
||||||
|
(doc) =>
|
||||||
|
this.validateStoredField(doc, "relativePath", "string") &&
|
||||||
|
this.validateStoredField(doc, "idempotencyKey", "string")
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const stored of validPendingDocuments) {
|
||||||
|
// If a live doc already exists at this path, skip the pending one
|
||||||
|
// only if the live doc is tracked (has metadata). If a pending doc
|
||||||
|
// already exists, skip duplicates.
|
||||||
|
const existing = this.pathIndex.get(stored.relativePath);
|
||||||
|
if (existing?.state === "pending") {
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping duplicate pending document at ${stored.relativePath}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc: PendingDocument = {
|
||||||
|
state: "pending",
|
||||||
|
relativePath: stored.relativePath,
|
||||||
|
idempotencyKey: stored.idempotencyKey,
|
||||||
|
originalCreationPath:
|
||||||
|
stored.originalCreationPath ?? stored.relativePath
|
||||||
|
};
|
||||||
|
|
||||||
|
// A pending doc at a path where a tracked doc exists: the pending
|
||||||
|
// doc takes precedence in pathIndex (mirrors old behavior where
|
||||||
|
// pending has higher parallelVersion).
|
||||||
|
this.pathIndex.set(doc.relativePath, doc);
|
||||||
|
this.idempotencyKeyIndex.set(doc.idempotencyKey, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ensureConsistency();
|
||||||
|
|
||||||
|
const totalDocs =
|
||||||
|
this.pathIndex.size + this.deletedLocallyDocuments().length;
|
||||||
|
this.logger.debug(`Loaded ${totalDocs} documents`);
|
||||||
|
|
||||||
|
const { lastSeenUpdateId } = state;
|
||||||
|
this.logger.debug(`Loaded last seen update id: ${lastSeenUpdateId}`);
|
||||||
|
this.lastSeenUpdateIds = new CoveredValues(
|
||||||
|
Math.max(0, lastSeenUpdateId ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seed CoveredValues with known server versions
|
||||||
|
for (const doc of this.documentIdIndex.values()) {
|
||||||
|
if (doc.state === "tracked") {
|
||||||
|
this.lastSeenUpdateIds.add(doc.serverVersion);
|
||||||
|
} else if (doc.state === "deleted-locally") {
|
||||||
|
this.lastSeenUpdateIds.add(doc.serverVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Validation helper
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private validateStoredField(
|
||||||
|
doc: object,
|
||||||
|
field: string,
|
||||||
|
expectedType: "string" | "number"
|
||||||
|
): boolean {
|
||||||
|
const value = (doc as Record<string, unknown>)[field];
|
||||||
|
if (
|
||||||
|
typeof value !== expectedType ||
|
||||||
|
(expectedType === "string" && !value) ||
|
||||||
|
(expectedType === "number" && isNaN(value as number))
|
||||||
|
) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Skipping stored document with invalid ${field}: ${JSON.stringify(doc)}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Queries
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public getByPath(path: string): VirtualDocument | undefined {
|
||||||
|
return this.pathIndex.get(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getByDocumentId(id: string): VirtualDocument | undefined {
|
||||||
|
return this.documentIdIndex.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getByIdempotencyKey(key: string): PendingDocument | undefined {
|
||||||
|
return this.idempotencyKeyIndex.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public trackedDocuments(): TrackedDocument[] {
|
||||||
|
const result: TrackedDocument[] = [];
|
||||||
|
for (const doc of this.pathIndex.values()) {
|
||||||
|
if (doc.state === "tracked") {
|
||||||
|
result.push(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public pendingDocuments(): PendingDocument[] {
|
||||||
|
const result: PendingDocument[] = [];
|
||||||
|
for (const doc of this.pathIndex.values()) {
|
||||||
|
if (doc.state === "pending") {
|
||||||
|
result.push(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public deletedLocallyDocuments(): DeletedLocallyDocument[] {
|
||||||
|
const result: DeletedLocallyDocument[] = [];
|
||||||
|
for (const doc of this.documentIdIndex.values()) {
|
||||||
|
if (doc.state === "deleted-locally") {
|
||||||
|
result.push(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All live documents (pending + tracked) that occupy a path. */
|
||||||
|
public allLiveDocuments(): VirtualDocument[] {
|
||||||
|
return Array.from(this.pathIndex.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total number of documents across all indexes (live + deleted-locally). */
|
||||||
|
public get length(): number {
|
||||||
|
// pathIndex has live docs (pending + tracked).
|
||||||
|
// documentIdIndex has tracked + deleted-locally.
|
||||||
|
// Tracked docs appear in both, so count:
|
||||||
|
// pending (pathIndex only) + tracked (both) + deleted-locally (documentIdIndex only)
|
||||||
|
// = pathIndex.size + deletedLocally count
|
||||||
|
let deletedCount = 0;
|
||||||
|
for (const doc of this.documentIdIndex.values()) {
|
||||||
|
if (doc.state === "deleted-locally") {
|
||||||
|
deletedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.pathIndex.size + deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public contains(doc: VirtualDocument): boolean {
|
||||||
|
switch (doc.state) {
|
||||||
|
case "pending":
|
||||||
|
return this.idempotencyKeyIndex.get(doc.idempotencyKey) === doc;
|
||||||
|
case "tracked":
|
||||||
|
return this.documentIdIndex.get(doc.documentId) === doc;
|
||||||
|
case "deleted-locally":
|
||||||
|
return this.documentIdIndex.get(doc.documentId) === doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Update ID tracking
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public getLastSeenUpdateId(): number {
|
||||||
|
return this.lastSeenUpdateIds.min;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addSeenUpdateId(value: number): void {
|
||||||
|
const previousMin = this.lastSeenUpdateIds.min;
|
||||||
|
this.lastSeenUpdateIds.add(value);
|
||||||
|
if (previousMin !== this.lastSeenUpdateIds.min) {
|
||||||
|
this.saveInTheBackground();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public setLastSeenUpdateId(value: number): void {
|
||||||
|
this.lastSeenUpdateIds.min = value;
|
||||||
|
this.saveInTheBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Mutations
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a pending document at the given path. If a pending document
|
||||||
|
* already exists at the path, return it (idempotent). Generates a new
|
||||||
|
* idempotency key via `crypto.randomUUID()`.
|
||||||
|
*
|
||||||
|
* Awaits save() so the idempotency key is persisted before any HTTP
|
||||||
|
* request is sent.
|
||||||
|
*/
|
||||||
|
public async createPending(path: string): Promise<PendingDocument> {
|
||||||
|
this.logger.debug(`Creating new pending document: ${path}`);
|
||||||
|
|
||||||
|
const existing = this.pathIndex.get(path);
|
||||||
|
if (existing?.state === "pending") {
|
||||||
|
this.logger.debug(
|
||||||
|
`Pending document already exists at ${path}, reusing it`
|
||||||
|
);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc: PendingDocument = {
|
||||||
|
state: "pending",
|
||||||
|
relativePath: path,
|
||||||
|
idempotencyKey: crypto.randomUUID(),
|
||||||
|
originalCreationPath: path
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pathIndex.set(path, doc);
|
||||||
|
this.idempotencyKeyIndex.set(doc.idempotencyKey, doc);
|
||||||
|
|
||||||
|
// Awaited so the idempotency key is persisted before any HTTP
|
||||||
|
// request is sent — a crash before save would lose the key.
|
||||||
|
await this.save();
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a pending create: transition from pending to tracked.
|
||||||
|
* Removes the pending doc and inserts a tracked doc with full metadata.
|
||||||
|
*/
|
||||||
|
public confirmCreate(
|
||||||
|
idempotencyKey: string,
|
||||||
|
documentId: DocumentId,
|
||||||
|
serverVersion: VaultUpdateId,
|
||||||
|
localHash: string,
|
||||||
|
remoteRelativePath: RelativePath
|
||||||
|
): TrackedDocument {
|
||||||
|
const pending = this.idempotencyKeyIndex.get(idempotencyKey);
|
||||||
|
if (pending === undefined) {
|
||||||
|
// The pending doc was already promoted to tracked by
|
||||||
|
// assignDocumentId (resolveIdempotencyKeys) or a previous
|
||||||
|
// confirmCreate call. Find the tracked doc and update it.
|
||||||
|
// Try by documentId first, then by scanning for the key.
|
||||||
|
let existing = this.documentIdIndex.get(documentId);
|
||||||
|
if (existing?.state !== "tracked") {
|
||||||
|
// The server may have assigned a different documentId
|
||||||
|
// (e.g., merge). Scan all tracked docs for the key.
|
||||||
|
for (const doc of this.documentIdIndex.values()) {
|
||||||
|
if (doc.state === "tracked" && doc.idempotencyKey === idempotencyKey) {
|
||||||
|
existing = doc;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existing?.state === "tracked") {
|
||||||
|
// If the server assigned a different documentId than what
|
||||||
|
// assignDocumentId set, update the index.
|
||||||
|
if (existing.documentId !== documentId) {
|
||||||
|
this.documentIdIndex.delete(existing.documentId);
|
||||||
|
existing.documentId = documentId;
|
||||||
|
this.documentIdIndex.set(documentId, existing);
|
||||||
|
}
|
||||||
|
existing.serverVersion = serverVersion;
|
||||||
|
existing.localHash = localHash;
|
||||||
|
existing.remoteRelativePath = remoteRelativePath;
|
||||||
|
existing.idempotencyKey = undefined;
|
||||||
|
this.lastSeenUpdateIds.add(serverVersion);
|
||||||
|
this.saveInTheBackground();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
// Truly not found — nothing to update
|
||||||
|
throw new Error(
|
||||||
|
`No pending document with idempotency key ${idempotencyKey}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracked: TrackedDocument = {
|
||||||
|
state: "tracked",
|
||||||
|
relativePath: pending.relativePath,
|
||||||
|
documentId,
|
||||||
|
serverVersion,
|
||||||
|
localHash,
|
||||||
|
remoteRelativePath
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove pending from indexes
|
||||||
|
this.idempotencyKeyIndex.delete(idempotencyKey);
|
||||||
|
|
||||||
|
// Update pathIndex (pending -> tracked at same path)
|
||||||
|
this.pathIndex.set(tracked.relativePath, tracked);
|
||||||
|
|
||||||
|
// Add to documentIdIndex
|
||||||
|
this.documentIdIndex.set(tracked.documentId, tracked);
|
||||||
|
|
||||||
|
this.lastSeenUpdateIds.add(serverVersion);
|
||||||
|
|
||||||
|
this.saveInTheBackground();
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign a documentId to a pending document (used by resolveIdempotencyKeys).
|
||||||
|
* Sets serverVersion = 0 as a placeholder — the sync path must treat
|
||||||
|
* serverVersion === 0 as needing a create retry.
|
||||||
|
*
|
||||||
|
* Returns the new TrackedDocument, or undefined if the key is not found.
|
||||||
|
*/
|
||||||
|
public assignDocumentId(
|
||||||
|
idempotencyKey: string,
|
||||||
|
documentId: DocumentId
|
||||||
|
): TrackedDocument | undefined {
|
||||||
|
const pending = this.idempotencyKeyIndex.get(idempotencyKey);
|
||||||
|
if (pending === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracked: TrackedDocument = {
|
||||||
|
state: "tracked",
|
||||||
|
relativePath: pending.relativePath,
|
||||||
|
documentId,
|
||||||
|
serverVersion: 0,
|
||||||
|
localHash: "",
|
||||||
|
remoteRelativePath: pending.relativePath,
|
||||||
|
idempotencyKey: pending.idempotencyKey
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove pending from indexes
|
||||||
|
this.idempotencyKeyIndex.delete(idempotencyKey);
|
||||||
|
|
||||||
|
// Update pathIndex
|
||||||
|
this.pathIndex.set(tracked.relativePath, tracked);
|
||||||
|
|
||||||
|
// Add to documentIdIndex
|
||||||
|
this.documentIdIndex.set(tracked.documentId, tracked);
|
||||||
|
|
||||||
|
this.saveInTheBackground();
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing tracked document's metadata.
|
||||||
|
*/
|
||||||
|
public updateTracked(
|
||||||
|
documentId: DocumentId,
|
||||||
|
serverVersion: VaultUpdateId,
|
||||||
|
localHash: string,
|
||||||
|
remoteRelativePath: RelativePath
|
||||||
|
): void {
|
||||||
|
const doc = this.documentIdIndex.get(documentId);
|
||||||
|
if (doc?.state !== "tracked") {
|
||||||
|
throw new Error(
|
||||||
|
`Tracked document with id ${documentId} not found`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.serverVersion = serverVersion;
|
||||||
|
doc.localHash = localHash;
|
||||||
|
doc.remoteRelativePath = remoteRelativePath;
|
||||||
|
|
||||||
|
this.lastSeenUpdateIds.add(serverVersion);
|
||||||
|
|
||||||
|
this.saveInTheBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a document from one path to another. Throws if the target path
|
||||||
|
* is occupied by a live document.
|
||||||
|
*/
|
||||||
|
public move(oldPath: string, newPath: string): void {
|
||||||
|
const doc = this.pathIndex.get(oldPath);
|
||||||
|
if (doc === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If another document occupies the target path, it was likely
|
||||||
|
// orphaned by an earlier displacement that wasn't reconciled.
|
||||||
|
// Remove it from the path index — reconcileWithDisk will
|
||||||
|
// re-discover the file if it still exists on disk.
|
||||||
|
const existingAtNew = this.pathIndex.get(newPath);
|
||||||
|
if (existingAtNew !== undefined && existingAtNew !== doc) {
|
||||||
|
this.pathIndex.delete(newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from old path
|
||||||
|
this.pathIndex.delete(oldPath);
|
||||||
|
|
||||||
|
// Update the document's relativePath
|
||||||
|
doc.relativePath = newPath;
|
||||||
|
|
||||||
|
// Insert at new path
|
||||||
|
this.pathIndex.set(newPath, doc);
|
||||||
|
|
||||||
|
this.saveInTheBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a document as deleted locally.
|
||||||
|
* - Pending: remove entirely (no server-side state to track).
|
||||||
|
* - Tracked: transition to deleted-locally (keep in documentIdIndex).
|
||||||
|
*/
|
||||||
|
public deleteLocally(path: string): void {
|
||||||
|
const doc = this.pathIndex.get(path);
|
||||||
|
if (doc === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from pathIndex in all cases
|
||||||
|
this.pathIndex.delete(path);
|
||||||
|
|
||||||
|
if (doc.state === "pending") {
|
||||||
|
// Remove from idempotencyKeyIndex too
|
||||||
|
this.idempotencyKeyIndex.delete(doc.idempotencyKey);
|
||||||
|
} else if (doc.state === "tracked") {
|
||||||
|
// Transition to deleted-locally
|
||||||
|
const deleted: DeletedLocallyDocument = {
|
||||||
|
state: "deleted-locally",
|
||||||
|
relativePath: doc.relativePath,
|
||||||
|
documentId: doc.documentId,
|
||||||
|
serverVersion: doc.serverVersion,
|
||||||
|
remoteRelativePath: doc.remoteRelativePath
|
||||||
|
};
|
||||||
|
// Replace in documentIdIndex
|
||||||
|
this.documentIdIndex.set(deleted.documentId, deleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveInTheBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a server-side delete: remove the document entirely.
|
||||||
|
*/
|
||||||
|
public confirmDelete(documentId: DocumentId): void {
|
||||||
|
const doc = this.documentIdIndex.get(documentId);
|
||||||
|
if (doc === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.documentIdIndex.delete(documentId);
|
||||||
|
|
||||||
|
// Also remove from pathIndex if present (tracked docs are in both)
|
||||||
|
if (doc.state === "tracked") {
|
||||||
|
const atPath = this.pathIndex.get(doc.relativePath);
|
||||||
|
if (atPath === doc) {
|
||||||
|
this.pathIndex.delete(doc.relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveInTheBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a document from all indexes entirely.
|
||||||
|
*/
|
||||||
|
public remove(doc: VirtualDocument): void {
|
||||||
|
switch (doc.state) {
|
||||||
|
case "pending": {
|
||||||
|
this.idempotencyKeyIndex.delete(doc.idempotencyKey);
|
||||||
|
const atPath = this.pathIndex.get(doc.relativePath);
|
||||||
|
if (atPath === doc) {
|
||||||
|
this.pathIndex.delete(doc.relativePath);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "tracked": {
|
||||||
|
this.documentIdIndex.delete(doc.documentId);
|
||||||
|
const atPath = this.pathIndex.get(doc.relativePath);
|
||||||
|
if (atPath === doc) {
|
||||||
|
this.pathIndex.delete(doc.relativePath);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "deleted-locally": {
|
||||||
|
this.documentIdIndex.delete(doc.documentId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveInTheBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure no other document has the given documentId. If a different
|
||||||
|
* document already holds it, remove that document and return it (so
|
||||||
|
* the caller can do optional file-level cleanup). Returns undefined
|
||||||
|
* if no conflict exists.
|
||||||
|
*/
|
||||||
|
public ensureUniqueDocumentId(
|
||||||
|
documentId: DocumentId,
|
||||||
|
keeper: VirtualDocument
|
||||||
|
): VirtualDocument | undefined {
|
||||||
|
const existing = this.documentIdIndex.get(documentId);
|
||||||
|
if (existing !== undefined && existing !== keeper) {
|
||||||
|
this.remove(existing);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Persistence
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
public async save(): Promise<void> {
|
||||||
|
const data = this.snapshotForSave();
|
||||||
|
const previousSave = this.pendingSave;
|
||||||
|
const thisSave = (async () => {
|
||||||
|
await previousSave.catch(() => {});
|
||||||
|
await this.saveData(data);
|
||||||
|
})();
|
||||||
|
this.pendingSave = thisSave.catch(() => {});
|
||||||
|
return thisSave;
|
||||||
|
}
|
||||||
|
|
||||||
|
public saveInTheBackground(): void {
|
||||||
|
this.ensureConsistency();
|
||||||
|
void this.save().catch((error: unknown) => {
|
||||||
|
this.logger.error(`Error saving data: ${error}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this.pathIndex.clear();
|
||||||
|
this.documentIdIndex.clear();
|
||||||
|
this.idempotencyKeyIndex.clear();
|
||||||
|
this.lastSeenUpdateIds = new CoveredValues(0);
|
||||||
|
this.saveInTheBackground();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize to StoredDatabase format for backward compatibility.
|
||||||
|
*/
|
||||||
|
private snapshotForSave(): StoredDatabase {
|
||||||
|
const documents: StoredDocumentMetadata[] = [];
|
||||||
|
|
||||||
|
// Tracked documents
|
||||||
|
for (const doc of this.pathIndex.values()) {
|
||||||
|
if (doc.state === "tracked") {
|
||||||
|
documents.push({
|
||||||
|
relativePath: doc.relativePath,
|
||||||
|
documentId: doc.documentId,
|
||||||
|
parentVersionId: doc.serverVersion,
|
||||||
|
hash: doc.localHash,
|
||||||
|
remoteRelativePath: doc.remoteRelativePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deleted-locally documents (with isDeleted flag)
|
||||||
|
for (const doc of this.documentIdIndex.values()) {
|
||||||
|
if (doc.state === "deleted-locally") {
|
||||||
|
documents.push({
|
||||||
|
relativePath: doc.relativePath,
|
||||||
|
documentId: doc.documentId,
|
||||||
|
parentVersionId: doc.serverVersion,
|
||||||
|
hash: "",
|
||||||
|
isDeleted: true,
|
||||||
|
remoteRelativePath: doc.remoteRelativePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending documents
|
||||||
|
const pendingDocuments: StoredPendingDocument[] = [];
|
||||||
|
for (const doc of this.idempotencyKeyIndex.values()) {
|
||||||
|
pendingDocuments.push({
|
||||||
|
relativePath: doc.relativePath,
|
||||||
|
idempotencyKey: doc.idempotencyKey,
|
||||||
|
originalCreationPath: doc.originalCreationPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
documents,
|
||||||
|
pendingDocuments,
|
||||||
|
lastSeenUpdateId: this.lastSeenUpdateIds.min
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Consistency check
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private ensureConsistency(): void {
|
||||||
|
// Check that documentIdIndex has no duplicates (by construction it
|
||||||
|
// shouldn't, since it's a Map keyed by documentId). But verify that
|
||||||
|
// pathIndex entries with documentIds are consistent.
|
||||||
|
const seenDocIds = new Set<string>();
|
||||||
|
for (const doc of this.pathIndex.values()) {
|
||||||
|
if (doc.state === "tracked") {
|
||||||
|
if (seenDocIds.has(doc.documentId)) {
|
||||||
|
throw new Error(
|
||||||
|
`Duplicate documentId ${doc.documentId} found in VFS pathIndex`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seenDocIds.add(doc.documentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const doc of this.documentIdIndex.values()) {
|
||||||
|
if (doc.state === "deleted-locally") {
|
||||||
|
if (seenDocIds.has(doc.documentId)) {
|
||||||
|
throw new Error(
|
||||||
|
`Duplicate documentId ${doc.documentId} found across live and deleted documents`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seenDocIds.add(doc.documentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Disk reconciliation
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare VFS entries against files on disk and produce a pure result
|
||||||
|
* describing what changed. Does NOT mutate the VFS.
|
||||||
|
*
|
||||||
|
* @param diskFiles - List of relative paths that currently exist on disk.
|
||||||
|
* @param readAndHash - Callback to read a file and return its hash, or
|
||||||
|
* undefined if the file cannot be read.
|
||||||
|
*/
|
||||||
|
public async reconcileWithDisk(
|
||||||
|
diskFiles: string[],
|
||||||
|
readAndHash: (path: string) => Promise<string | undefined>
|
||||||
|
): Promise<ReconciliationResult> {
|
||||||
|
const diskSet = new Set(diskFiles);
|
||||||
|
|
||||||
|
const newFiles: string[] = [];
|
||||||
|
const modifiedFiles: { path: string; documentId: string }[] = [];
|
||||||
|
const missingFiles: VirtualDocument[] = [];
|
||||||
|
const movedFiles: { document: TrackedDocument; newPath: string }[] = [];
|
||||||
|
|
||||||
|
// Collect missing tracked/pending docs (file not on disk)
|
||||||
|
const missingTracked: TrackedDocument[] = [];
|
||||||
|
for (const doc of this.pathIndex.values()) {
|
||||||
|
if (!diskSet.has(doc.relativePath)) {
|
||||||
|
if (doc.state === "tracked") {
|
||||||
|
missingTracked.push(doc);
|
||||||
|
}
|
||||||
|
missingFiles.push(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each disk file, classify it
|
||||||
|
for (const path of diskFiles) {
|
||||||
|
const doc = this.pathIndex.get(path);
|
||||||
|
|
||||||
|
if (doc === undefined) {
|
||||||
|
// File on disk, not in VFS — could be new or a move
|
||||||
|
newFiles.push(path);
|
||||||
|
} else if (doc.state === "tracked") {
|
||||||
|
// Check if content changed
|
||||||
|
const fileHash = await readAndHash(path);
|
||||||
|
if (
|
||||||
|
fileHash !== undefined &&
|
||||||
|
fileHash !== doc.localHash
|
||||||
|
) {
|
||||||
|
modifiedFiles.push({
|
||||||
|
path,
|
||||||
|
documentId: doc.documentId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If pending, nothing to reconcile — it's already pending
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt move detection: for each new file, try to match against
|
||||||
|
// a missing tracked doc by content hash
|
||||||
|
if (missingTracked.length > 0 && newFiles.length > 0) {
|
||||||
|
const remainingNew: string[] = [];
|
||||||
|
|
||||||
|
for (const path of newFiles) {
|
||||||
|
const fileHash = await readAndHash(path);
|
||||||
|
if (fileHash === undefined || fileHash === EMPTY_HASH) {
|
||||||
|
remainingNew.push(path);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a single unique match among missing tracked docs
|
||||||
|
const matches = missingTracked.filter(
|
||||||
|
(doc) => doc.localHash === fileHash
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matches.length === 1) {
|
||||||
|
const match = matches[0];
|
||||||
|
movedFiles.push({ document: match, newPath: path });
|
||||||
|
|
||||||
|
// Remove from missingTracked so it can't match again
|
||||||
|
const idx = missingTracked.indexOf(match);
|
||||||
|
if (idx !== -1) {
|
||||||
|
missingTracked.splice(idx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from missingFiles too
|
||||||
|
const missingIdx = missingFiles.indexOf(match);
|
||||||
|
if (missingIdx !== -1) {
|
||||||
|
missingFiles.splice(missingIdx, 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
remainingNew.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace newFiles with the remaining unmatched ones
|
||||||
|
newFiles.length = 0;
|
||||||
|
newFiles.push(...remainingNew);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { newFiles, modifiedFiles, missingFiles, movedFiles };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import { describe, it, mock, beforeEach, afterEach } from "node:test";
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import { FetchController } from "./fetch-controller";
|
import { FetchController } from "./fetch-controller";
|
||||||
import { Logger } from "../tracing/logger";
|
import { Logger } from "../tracing/logger";
|
||||||
import { SyncResetError } from "./sync-reset-error";
|
import { SyncResetError } from "../errors/sync-reset-error";
|
||||||
import { sleep } from "../utils/sleep";
|
import { sleep } from "../utils/sleep";
|
||||||
|
|
||||||
describe("FetchController", () => {
|
describe("FetchController", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import type { Logger } from "../tracing/logger";
|
import type { Logger } from "../tracing/logger";
|
||||||
import { createPromise } from "../utils/create-promise";
|
import { createPromise } from "../utils/create-promise";
|
||||||
import { SyncResetError } from "./sync-reset-error";
|
import { SyncResetError } from "../errors/sync-reset-error";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offers a resettable fetch implementation that waits until syncing is enabled
|
* Offers a resettable fetch implementation that waits until syncing is enabled
|
||||||
* and aborts outstanding requests when a reset is started.
|
* and aborts outstanding requests when a reset is started.
|
||||||
*/
|
*/
|
||||||
|
const HTTP_REQUEST_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
export class FetchController {
|
export class FetchController {
|
||||||
private static readonly UNTIL_RESOLUTION = Symbol();
|
private static readonly UNTIL_RESOLUTION = Symbol();
|
||||||
|
|
||||||
|
|
@ -25,18 +27,18 @@ export class FetchController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the fetch implementation can immediately send requests once outside of a reset.
|
* Whether the fetch implementation can immediately send requests once outside of a reset.
|
||||||
*/
|
*/
|
||||||
public get canFetch(): boolean {
|
public get canFetch(): boolean {
|
||||||
return this._canFetch;
|
return this._canFetch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allow or disallow fetching. The changes only take effect if not resetting.
|
* Allow or disallow fetching. The changes only take effect if not resetting.
|
||||||
* When called during a reset, its effect is deferred until the reset is finished.
|
* When called during a reset, its effect is deferred until the reset is finished.
|
||||||
*
|
*
|
||||||
* @param canFetch Whether fetching is enabled
|
* @param canFetch Whether fetching is enabled
|
||||||
*/
|
*/
|
||||||
public set canFetch(canFetch: boolean) {
|
public set canFetch(canFetch: boolean) {
|
||||||
this._canFetch = canFetch;
|
this._canFetch = canFetch;
|
||||||
|
|
||||||
|
|
@ -59,9 +61,9 @@ export class FetchController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a reset, causing all ongoing and future fetches to be rejected
|
* Starts a reset, causing all ongoing and future fetches to be rejected
|
||||||
* with a SyncResetError until finishReset is called.
|
* with a SyncResetError until finishReset is called.
|
||||||
*/
|
*/
|
||||||
public startReset(): void {
|
public startReset(): void {
|
||||||
this.isResetting = true;
|
this.isResetting = true;
|
||||||
this.rejectUntil(new SyncResetError());
|
this.rejectUntil(new SyncResetError());
|
||||||
|
|
@ -72,32 +74,42 @@ export class FetchController {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finishes a reset, allowing fetches to proceed or wait again depending on
|
* Finishes a reset, allowing fetches to proceed or wait again depending on
|
||||||
* the current sync settings.
|
* the current sync settings.
|
||||||
*/
|
*/
|
||||||
public finishReset(): void {
|
public finishReset(): void {
|
||||||
if (!this.isResetting) {
|
if (!this.isResetting) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isResetting = false;
|
this.isResetting = false;
|
||||||
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
|
// Capture the old resolve before creating a fresh promise, then
|
||||||
|
// resolve the old one — exactly the same pattern the canFetch
|
||||||
|
// setter uses. This wakes up any fetches that entered the
|
||||||
|
// while-loop between startReset and finishReset so they re-check
|
||||||
|
// the condition. Without this, a canFetch change that occurred
|
||||||
|
// during the reset (setter skips resolution while isResetting is
|
||||||
|
// true) would leave fetches blocking on an unresolved promise.
|
||||||
|
const previousResolve = this.resolveUntil;
|
||||||
|
[this.until, this.resolveUntil, this.rejectUntil] =
|
||||||
|
createPromise<symbol>();
|
||||||
|
previousResolve(FetchController.UNTIL_RESOLUTION);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* |------------------|---------------|-----------------------------------------------------|
|
* |------------------|---------------|-----------------------------------------------------|
|
||||||
* | | Sync enabled | Sync disabled |
|
* | | Sync enabled | Sync disabled |
|
||||||
* |------------------|-------------- |-----------------------------------------------------|
|
* |------------------|-------------- |-----------------------------------------------------|
|
||||||
* | During reset | Rejects with SyncResetError without sending request |
|
* | During reset | Rejects with SyncResetError without sending request |
|
||||||
* |------------------|-------------- |-----------------------------------------------------|
|
* |------------------|-------------- |-----------------------------------------------------|
|
||||||
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
|
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
|
||||||
* |------------------|---------------|-----------------------------------------------------|
|
* |------------------|---------------|-----------------------------------------------------|
|
||||||
*
|
*
|
||||||
* @param logger for errors
|
* @param logger for errors
|
||||||
* @param fetch to wrap
|
* @param fetch to wrap
|
||||||
* @returns a wrapped fetch implementation affected by the FetchController state
|
* @returns a wrapped fetch implementation affected by the FetchController state
|
||||||
*/
|
*/
|
||||||
public getControlledFetchImplementation(
|
public getControlledFetchImplementation(
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
fetch: typeof globalThis.fetch = globalThis.fetch
|
fetch: typeof globalThis.fetch = globalThis.fetch
|
||||||
|
|
@ -117,7 +129,17 @@ export class FetchController {
|
||||||
? input.clone()
|
? input.clone()
|
||||||
: input;
|
: input;
|
||||||
|
|
||||||
const fetchPromise = fetch(_input, init);
|
const combinedSignal = init?.signal
|
||||||
|
? AbortSignal.any([
|
||||||
|
AbortSignal.timeout(HTTP_REQUEST_TIMEOUT_MS),
|
||||||
|
init.signal
|
||||||
|
])
|
||||||
|
: AbortSignal.timeout(HTTP_REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const fetchPromise = fetch(_input, {
|
||||||
|
...init,
|
||||||
|
signal: combinedSignal
|
||||||
|
});
|
||||||
|
|
||||||
// We only want to catch rejections from `this.until`
|
// We only want to catch rejections from `this.until`
|
||||||
let result: symbol | Response | undefined = undefined;
|
let result: symbol | Response | undefined = undefined;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { SUPPORTED_API_VERSION } from "../consts";
|
import { SUPPORTED_API_VERSION } from "../consts";
|
||||||
import { AuthenticationError } from "./authentication-error";
|
import { AuthenticationError } from "../errors/authentication-error";
|
||||||
import { ServerVersionMismatchError } from "./server-version-mismatch-error";
|
import { ServerVersionMismatchError } from "../errors/server-version-mismatch-error";
|
||||||
import type { SyncService } from "./sync-service";
|
import type { SyncService } from "./sync-service";
|
||||||
import type { PingResponse } from "./types/PingResponse";
|
import type { PingResponse } from "./types/PingResponse";
|
||||||
|
|
||||||
|
|
@ -34,11 +34,6 @@ export class ServerConfig {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// warm the cache
|
|
||||||
public async initialize(): Promise<void> {
|
|
||||||
await this.getConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async checkConnection(forceUpdate = false): Promise<{
|
public async checkConnection(forceUpdate = false): Promise<{
|
||||||
isSuccessful: boolean;
|
isSuccessful: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ import type { Logger } from "../tracing/logger";
|
||||||
import type { Settings } from "../persistence/settings";
|
import type { Settings } from "../persistence/settings";
|
||||||
import type { FetchController } from "./fetch-controller";
|
import type { FetchController } from "./fetch-controller";
|
||||||
import { sleep } from "../utils/sleep";
|
import { sleep } from "../utils/sleep";
|
||||||
import { SyncResetError } from "./sync-reset-error";
|
import { SyncResetError } from "../errors/sync-reset-error";
|
||||||
|
import { HttpClientError } from "../errors/http-client-error";
|
||||||
import type { SerializedError } from "./types/SerializedError";
|
import type { SerializedError } from "./types/SerializedError";
|
||||||
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
||||||
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
|
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
|
||||||
|
|
@ -48,13 +49,44 @@ export class SyncService {
|
||||||
.get("Content-Type")
|
.get("Content-Type")
|
||||||
?.includes("application/json") == true
|
?.includes("application/json") == true
|
||||||
) {
|
) {
|
||||||
const result: SerializedError =
|
try {
|
||||||
(await response.json()) as SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
const result: SerializedError =
|
||||||
return SyncService.formatError(result);
|
(await response.json()) as SerializedError; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
return SyncService.formatError(result);
|
||||||
|
} catch {
|
||||||
|
return `HTTP ${response.status}: ${response.statusText} (failed to parse error response body)`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return `HTTP ${response.status}: ${response.statusText}`;
|
return `HTTP ${response.status}: ${response.statusText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parse JSON from a response body. If parsing fails (e.g., malformed
|
||||||
|
* JSON from the server), throws an HttpClientError with status 0 so that
|
||||||
|
* retryForever does not retry indefinitely.
|
||||||
|
*/
|
||||||
|
private static async parseJsonResponse<T>(
|
||||||
|
response: Response
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
return (await response.json()) as T; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||||
|
} catch (error) {
|
||||||
|
// Timeout and abort errors are transient — let them propagate
|
||||||
|
// so retryForever can retry. Only wrap genuine parse failures
|
||||||
|
// (malformed JSON) as HttpClientError to prevent infinite retries.
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.name === "TimeoutError" || error.name === "AbortError")
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new HttpClientError(
|
||||||
|
0,
|
||||||
|
`Failed to parse JSON response: ${error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static formatError(error: SerializedError): string {
|
private static formatError(error: SerializedError): string {
|
||||||
let result = error.message;
|
let result = error.message;
|
||||||
if (error.causes.length > 0) {
|
if (error.causes.length > 0) {
|
||||||
|
|
@ -65,28 +97,41 @@ export class SyncService {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async throwHttpError(
|
||||||
|
response: Response,
|
||||||
|
context: string
|
||||||
|
): Promise<never> {
|
||||||
|
const message = `${context}: ${await SyncService.errorFromResponse(response)}`;
|
||||||
|
if (response.status >= 400 && response.status < 500) {
|
||||||
|
throw new HttpClientError(response.status, message);
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
public async create({
|
public async create({
|
||||||
documentId,
|
|
||||||
relativePath,
|
relativePath,
|
||||||
contentBytes
|
contentBytes,
|
||||||
|
idempotencyKey
|
||||||
}: {
|
}: {
|
||||||
documentId?: DocumentId;
|
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
contentBytes: Uint8Array;
|
contentBytes: Uint8Array;
|
||||||
}): Promise<DocumentVersionWithoutContent> {
|
idempotencyKey?: string;
|
||||||
|
}): Promise<DocumentUpdateResponse> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
if (documentId !== undefined) {
|
|
||||||
formData.append("document_id", documentId);
|
|
||||||
}
|
|
||||||
formData.append("relative_path", relativePath);
|
formData.append("relative_path", relativePath);
|
||||||
formData.append(
|
formData.append(
|
||||||
"content",
|
"content",
|
||||||
new Blob([new Uint8Array(contentBytes)])
|
new Blob([new Uint8Array(contentBytes)])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (idempotencyKey !== undefined) {
|
||||||
|
formData.append("idempotency_key", idempotencyKey);
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Creating document with id ${documentId} and relative path ${relativePath}`
|
`Creating document with relative path ${relativePath}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await this.client(this.getUrl("/documents"), {
|
const response = await this.client(this.getUrl("/documents"), {
|
||||||
|
|
@ -96,15 +141,16 @@ export class SyncService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
await SyncService.throwHttpError(
|
||||||
`Failed to create document: ${await SyncService.errorFromResponse(
|
response,
|
||||||
response
|
"Failed to create document"
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentVersionWithoutContent =
|
const result: DocumentUpdateResponse =
|
||||||
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
await SyncService.parseJsonResponse<DocumentUpdateResponse>(
|
||||||
|
response
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.debug(`Created document ${JSON.stringify(result)}`);
|
this.logger.debug(`Created document ${JSON.stringify(result)}`);
|
||||||
|
|
||||||
|
|
@ -144,19 +190,19 @@ export class SyncService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
await SyncService.throwHttpError(
|
||||||
`Failed to update document: ${await SyncService.errorFromResponse(
|
response,
|
||||||
response
|
"Failed to update document"
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentUpdateResponse =
|
const result: DocumentUpdateResponse =
|
||||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
await SyncService.parseJsonResponse<DocumentUpdateResponse>(
|
||||||
|
response
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updated document ${JSON.stringify(result)} with id ${
|
`Updated document ${JSON.stringify(result)} with id ${result.documentId
|
||||||
result.documentId
|
|
||||||
}}`
|
}}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -197,19 +243,19 @@ export class SyncService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
await SyncService.throwHttpError(
|
||||||
`Failed to update document: ${await SyncService.errorFromResponse(
|
response,
|
||||||
response
|
"Failed to update document"
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentUpdateResponse =
|
const result: DocumentUpdateResponse =
|
||||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
await SyncService.parseJsonResponse<DocumentUpdateResponse>(
|
||||||
|
response
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Updated document ${JSON.stringify(result)} with id ${
|
`Updated document ${JSON.stringify(result)} with id ${result.documentId
|
||||||
result.documentId
|
|
||||||
}}`
|
}}`
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -225,9 +271,7 @@ export class SyncService {
|
||||||
relativePath: RelativePath;
|
relativePath: RelativePath;
|
||||||
}): Promise<DocumentVersionWithoutContent> {
|
}): Promise<DocumentVersionWithoutContent> {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
const request: DeleteDocumentVersion = {
|
const request: DeleteDocumentVersion = {};
|
||||||
relativePath
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Delete document with id ${documentId} and relative path ${relativePath}`
|
`Delete document with id ${documentId} and relative path ${relativePath}`
|
||||||
|
|
@ -243,15 +287,16 @@ export class SyncService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
await SyncService.throwHttpError(
|
||||||
`Failed to delete document: ${await SyncService.errorFromResponse(
|
response,
|
||||||
response
|
"Failed to delete document"
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentVersionWithoutContent =
|
const result: DocumentVersionWithoutContent =
|
||||||
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
await SyncService.parseJsonResponse<DocumentVersionWithoutContent>(
|
||||||
|
response
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Deleted document ${relativePath} with id ${documentId}`
|
`Deleted document ${relativePath} with id ${documentId}`
|
||||||
|
|
@ -277,15 +322,16 @@ export class SyncService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
await SyncService.throwHttpError(
|
||||||
`Failed to get document: ${await SyncService.errorFromResponse(
|
response,
|
||||||
response
|
"Failed to get document"
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: DocumentVersion =
|
const result: DocumentVersion =
|
||||||
(await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
await SyncService.parseJsonResponse<DocumentVersion>(
|
||||||
|
response
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.debug(`Got document ${JSON.stringify(result)}`);
|
this.logger.debug(`Got document ${JSON.stringify(result)}`);
|
||||||
|
|
||||||
|
|
@ -315,10 +361,9 @@ export class SyncService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
await SyncService.throwHttpError(
|
||||||
`Failed to get document: ${await SyncService.errorFromResponse(
|
response,
|
||||||
response
|
"Failed to get document"
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -336,7 +381,7 @@ export class SyncService {
|
||||||
return this.retryForever(async () => {
|
return this.retryForever(async () => {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
"Getting all documents" +
|
"Getting all documents" +
|
||||||
(since != null ? ` since ${since}` : "")
|
(since != null ? ` since ${since}` : "")
|
||||||
);
|
);
|
||||||
|
|
||||||
const url = new URL(this.getUrl("/documents"));
|
const url = new URL(this.getUrl("/documents"));
|
||||||
|
|
@ -348,15 +393,16 @@ export class SyncService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
await SyncService.throwHttpError(
|
||||||
`Failed to get documents: ${await SyncService.errorFromResponse(
|
response,
|
||||||
response
|
"Failed to get documents"
|
||||||
)}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: FetchLatestDocumentsResponse =
|
const result: FetchLatestDocumentsResponse =
|
||||||
(await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
await SyncService.parseJsonResponse<FetchLatestDocumentsResponse>(
|
||||||
|
response
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Got ${result.latestDocuments.length} document metadata`
|
`Got ${result.latestDocuments.length} document metadata`
|
||||||
|
|
@ -366,6 +412,47 @@ export class SyncService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async resolveIdempotencyKeys(
|
||||||
|
keys: string[]
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
this.logger.debug(
|
||||||
|
`Resolving ${keys.length} idempotency keys`
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.retryForever(async () => {
|
||||||
|
const response = await this.client(
|
||||||
|
this.getUrl("/documents/resolve-keys"),
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ idempotencyKeys: keys }),
|
||||||
|
headers: this.getDefaultHeaders({ type: "json" })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
await SyncService.throwHttpError(
|
||||||
|
response,
|
||||||
|
"Failed to resolve idempotency keys"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result =
|
||||||
|
await SyncService.parseJsonResponse<{
|
||||||
|
resolved: Record<string, string>;
|
||||||
|
}>(response);
|
||||||
|
|
||||||
|
const resolved = new Map<string, string>(
|
||||||
|
Object.entries(result.resolved)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Resolved ${resolved.size}/${keys.length} idempotency keys`
|
||||||
|
);
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public async ping(): Promise<PingResponse> {
|
public async ping(): Promise<PingResponse> {
|
||||||
this.logger.debug("Pinging server");
|
this.logger.debug("Pinging server");
|
||||||
const response = await this.pingClient(this.getUrl("/ping"), {
|
const response = await this.pingClient(this.getUrl("/ping"), {
|
||||||
|
|
@ -380,7 +467,8 @@ export class SyncService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: PingResponse = (await response.json()) as PingResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
const result: PingResponse =
|
||||||
|
await SyncService.parseJsonResponse<PingResponse>(response);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Pinged server, got response: ${JSON.stringify(result)}`
|
`Pinged server, got response: ${JSON.stringify(result)}`
|
||||||
|
|
@ -412,6 +500,7 @@ export class SyncService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
|
private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
let attempt = 0;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -422,12 +511,25 @@ export class SyncService {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
const retryInterval =
|
// Don't retry 4xx client errors — the request itself is wrong
|
||||||
|
// and retrying won't help
|
||||||
|
if (e instanceof HttpClientError) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt++;
|
||||||
|
const baseDelay =
|
||||||
this.settings.getSettings().networkRetryIntervalMs;
|
this.settings.getSettings().networkRetryIntervalMs;
|
||||||
this.logger.error(
|
const exponentialDelay = Math.min(
|
||||||
`Failed network call (${e}), retrying in ${retryInterval}ms`
|
baseDelay * Math.pow(2, Math.min(attempt - 1, 5)),
|
||||||
|
30000
|
||||||
);
|
);
|
||||||
await sleep(retryInterval);
|
const jitter = Math.random() * exponentialDelay * 0.5;
|
||||||
|
const delay = exponentialDelay + jitter;
|
||||||
|
this.logger.error(
|
||||||
|
`Failed network call (${e}), retrying in ${Math.round(delay)}ms (attempt ${attempt})`
|
||||||
|
);
|
||||||
|
await sleep(delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||||
|
|
||||||
export interface ClientCursors {
|
export interface ClientCursors { userName: string, deviceId: string, documentsWithCursors: DocumentWithCursors[], }
|
||||||
userName: string;
|
|
||||||
deviceId: string;
|
|
||||||
documentsWithCursors: DocumentWithCursors[];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,3 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface CreateDocumentVersion {
|
export interface CreateDocumentVersion { relative_path: string, content: number[], }
|
||||||
/**
|
|
||||||
* The client can decide the document id (if it wishes to) in order
|
|
||||||
* to help with syncing. If the client does not provide a document id,
|
|
||||||
* the server will generate one. If the client provides a document id
|
|
||||||
* it must not already exist in the database.
|
|
||||||
*/
|
|
||||||
document_id: string | null;
|
|
||||||
relative_path: string;
|
|
||||||
content: number[];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||||
|
|
||||||
export interface CursorPositionFromClient {
|
export interface CursorPositionFromClient { documentsWithCursors: DocumentWithCursors[], }
|
||||||
documentsWithCursors: DocumentWithCursors[];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
import type { ClientCursors } from "./ClientCursors";
|
import type { ClientCursors } from "./ClientCursors";
|
||||||
|
|
||||||
export interface CursorPositionFromServer {
|
export interface CursorPositionFromServer { clients: ClientCursors[], }
|
||||||
clients: ClientCursors[];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface CursorSpan {
|
export interface CursorSpan { start: number, end: number, }
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface DeleteDocumentVersion {
|
export type DeleteDocumentVersion = Record<string, never>;
|
||||||
relativePath: string;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,4 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
|
||||||
/**
|
/**
|
||||||
* Response to an update document request.
|
* Response to an update document request.
|
||||||
*/
|
*/
|
||||||
export type DocumentUpdateResponse =
|
export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion;
|
||||||
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
|
|
||||||
| ({ type: "MergingUpdate" } & DocumentVersion);
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,3 @@
|
||||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
export interface DocumentVersion {
|
export interface DocumentVersion { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }
|
||||||
vaultUpdateId: number;
|
|
||||||
documentId: string;
|
|
||||||
relativePath: string;
|
|
||||||
updatedDate: string;
|
|
||||||
contentBase64: string;
|
|
||||||
isDeleted: boolean;
|
|
||||||
userId: string;
|
|
||||||
deviceId: string;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue
The packages
commanderandwatcherare listed underdevDependenciesbut should be independenciessince they're required at runtime for the CLI tool to function. devDependencies are only for build-time and testing tools.