Compare commits
11 commits
main
...
asch/split
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a070340f1 | |||
| 42c9d55489 | |||
| 0fda95ff8e | |||
| 45b86cffe4 | |||
| 9d99a4ac23 | |||
| f7beb31d8f | |||
| 042233c4d7 | |||
| 4ba439b874 | |||
| 2d5edc6ec5 | |||
| a9ce09b59d | |||
| 70f97c4b16 |
178 changed files with 16230 additions and 6287 deletions
35
.forgejo/workflows/check.yml
Normal file
35
.forgejo/workflows/check.yml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
name: Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-Dwarnings"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "25.x"
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.92.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Lint & test
|
||||
run: scripts/check.sh
|
||||
38
.forgejo/workflows/deploy-docs.yml
Normal file
38
.forgejo/workflows/deploy-docs.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- ".forgejo/workflows/deploy-docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "25.x"
|
||||
|
||||
- name: Build docs
|
||||
run: scripts/build-docs.sh
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docs
|
||||
path: docs/.vitepress/dist
|
||||
71
.forgejo/workflows/e2e.yml
Normal file
71
.forgejo/workflows/e2e.yml
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
name: E2E tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: e2e-tests
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
RUSTFLAGS: "-Dwarnings"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "25.x"
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.92.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Setup rust
|
||||
run: |
|
||||
which sqlx || cargo install sqlx-cli
|
||||
cd sync-server
|
||||
sqlx database create --database-url sqlite://db.sqlite3
|
||||
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||
|
||||
- name: E2E tests
|
||||
run: |
|
||||
cd sync-server
|
||||
cargo run config-e2e.yml --color never &
|
||||
SERVER_PID=$!
|
||||
cd ..
|
||||
|
||||
scripts/e2e.sh 8
|
||||
EXIT_CODE=$?
|
||||
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
wait $SERVER_PID 2>/dev/null || true
|
||||
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Upload e2e logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-logs
|
||||
path: logs/
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: scripts/clean-up.sh
|
||||
51
.forgejo/workflows/publish-cli-docker.yml
Normal file
51
.forgejo/workflows/publish-cli-docker.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
name: Publish CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: ubuntu-docker
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract registry hostname
|
||||
id: registry
|
||||
run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.registry.outputs.host }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.registry.outputs.host }}/${{ github.repository }}-cli
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: frontend
|
||||
file: frontend/local-client-cli/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache
|
||||
cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}-cli:buildcache,mode=max
|
||||
71
.forgejo/workflows/publish-plugin.yml
Normal file
71
.forgejo/workflows/publish-plugin.yml
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
name: Publish Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["*"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
publish-plugin:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "25.x"
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.92.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Install cross-compilation tools
|
||||
run: |
|
||||
apt update
|
||||
apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64 jq
|
||||
|
||||
- name: Build Linux and Windows binaries
|
||||
run: ./scripts/build-sync-server-binaries.sh
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
mkdir -p release
|
||||
cp frontend/obsidian-plugin/dist/* release/
|
||||
cp sync-server/artifacts/sync-server-* release/
|
||||
|
||||
# Create draft release via Forgejo API
|
||||
RELEASE_ID=$(curl -s -X POST \
|
||||
"${SERVER_URL}/api/v1/repos/${REPO}/releases" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${tag}\", \"name\": \"${tag}\", \"draft\": true}" \
|
||||
| jq -r '.id')
|
||||
|
||||
# Upload release assets
|
||||
for file in release/*; do
|
||||
filename=$(basename "$file")
|
||||
curl -s -X POST \
|
||||
"${SERVER_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-F "attachment=@${file}"
|
||||
done
|
||||
51
.forgejo/workflows/publish-server-docker.yml
Normal file
51
.forgejo/workflows/publish-server-docker.yml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
name: Publish server Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: ubuntu-docker
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract registry hostname
|
||||
id: registry
|
||||
run: echo "host=$(echo '${{ github.server_url }}' | sed 's|https\?://||')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into container registry
|
||||
if: github.ref_type == 'tag'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ steps.registry.outputs.host }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.registry.outputs.host }}/${{ github.repository }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: sync-server
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache
|
||||
cache-to: type=registry,ref=${{ steps.registry.outputs.host }}/${{ github.repository }}:buildcache,mode=max
|
||||
4
.github/workflows/check.yml
vendored
4
.github/workflows/check.yml
vendored
|
|
@ -23,13 +23,13 @@ jobs:
|
|||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "22.x"
|
||||
node-version: "25.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.89.0"
|
||||
toolchain: "1.92.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Lint & test
|
||||
|
|
|
|||
13
.github/workflows/deploy-docs.yml
vendored
13
.github/workflows/deploy-docs.yml
vendored
|
|
@ -5,8 +5,8 @@ on:
|
|||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
- "docs/**"
|
||||
- ".github/workflows/deploy-docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
|
@ -28,12 +28,11 @@ jobs:
|
|||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: docs/package-lock.json
|
||||
node-version: "25.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
|
|
|||
6
.github/workflows/e2e.yml
vendored
6
.github/workflows/e2e.yml
vendored
|
|
@ -6,7 +6,7 @@ on:
|
|||
pull_request:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: '0 * * * *'
|
||||
- cron: "0 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
|
@ -28,13 +28,13 @@ jobs:
|
|||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "22.x"
|
||||
node-version: "25.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.89.0"
|
||||
toolchain: "1.92.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- 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
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "22.x"
|
||||
node-version: "25.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Build plugin
|
||||
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.89.0"
|
||||
toolchain: "1.92.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Install cross-compilation tools
|
||||
|
|
|
|||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -7,15 +7,18 @@ node_modules
|
|||
# Frontend build folders
|
||||
frontend/*/dist
|
||||
|
||||
sync-server/db.sqlite3*
|
||||
sync-server/databases
|
||||
|
||||
# Rust build folders
|
||||
sync-server/target
|
||||
sync-server/artifacts
|
||||
sync-server/bindings/*.ts
|
||||
|
||||
# build folders
|
||||
sync-server/db.sqlite3*
|
||||
**/databases
|
||||
|
||||
*.log
|
||||
*.sqlx
|
||||
|
||||
target
|
||||
|
||||
.task
|
||||
|
|
|
|||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
|
@ -5,6 +5,6 @@
|
|||
"**/dist": true,
|
||||
"**/node_modules": true,
|
||||
"**/.sqlx": true,
|
||||
"**/target": true,
|
||||
},
|
||||
"**/target": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
195
CLAUDE.md
195
CLAUDE.md
|
|
@ -2,109 +2,154 @@
|
|||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
## Project shape
|
||||
|
||||
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 file-sync system. Two halves of one repo:
|
||||
|
||||
## Architecture
|
||||
- `sync-server/` — Rust (axum + sqlx/SQLite). Source of truth for vault state, broadcasts changes via WebSocket.
|
||||
- `frontend/` — npm workspaces. The sync engine (`sync-client`) is consumed by an Obsidian plugin, a standalone CLI, a fuzz E2E harness, a scripted determinism harness, and a history UI.
|
||||
|
||||
### Core Components
|
||||
The HTTP/WS API types are generated from Rust (`ts-rs`) and mirrored into the TS workspaces. **Never hand-edit files in `frontend/sync-client/src/services/types/` or `frontend/history-ui/src/lib/types/`** — run `scripts/update-api-types.sh` after changing anything Serde-derived in the server.
|
||||
|
||||
- **sync-server/**: Rust-based WebSocket server with SQLite database for document versioning and real-time synchronization
|
||||
- **frontend/sync-client/**: TypeScript library providing core sync functionality, WebSocket management, and file operations
|
||||
- **frontend/obsidian-plugin/**: Obsidian plugin that integrates the sync client with Obsidian's API
|
||||
- **frontend/test-client/**: CLI testing tool for the sync functionality
|
||||
### Frontend workspaces
|
||||
|
||||
### Key Technologies
|
||||
- `sync-client` — the sync engine; published to consumers via `dist/`. All other TS workspaces depend on it via `file:../sync-client`.
|
||||
- `obsidian-plugin` — Obsidian plugin built from `sync-client`.
|
||||
- `local-client-cli` — same engine wrapped as a standalone CLI.
|
||||
- `history-ui` — vault-history web UI.
|
||||
- `test-client` — fuzz E2E harness (random ops across N processes).
|
||||
- `deterministic-tests` — scripted multi-client tests with an in-memory FS, run against a real server.
|
||||
|
||||
- **Backend**: Rust with Axum framework, SQLite with SQLx, WebSockets for real-time sync
|
||||
- **Frontend**: TypeScript, Webpack for bundling, Jest for testing
|
||||
- **Sync Algorithm**: Uses reconcile-text library for operational transformation
|
||||
## Common commands
|
||||
|
||||
## Development Commands
|
||||
Pre-push hygiene (formats, lints, runs tests, requires clean git state):
|
||||
|
||||
### Server Development
|
||||
```bash
|
||||
cd sync-server
|
||||
cargo run config-e2e.yml # Start development server
|
||||
cargo test --verbose # Run Rust tests
|
||||
cargo clippy --all-targets --all-features # Lint Rust code
|
||||
cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged # Auto-fix clippy warnings
|
||||
cargo fmt --all -- --check # Check Rust formatting
|
||||
cargo fmt --all # Auto-format Rust code
|
||||
cargo machete --with-metadata # Detect unused dependencies
|
||||
```sh
|
||||
scripts/check.sh --fix
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
```bash
|
||||
Run the fuzz E2E (N parallel processes):
|
||||
|
||||
```sh
|
||||
scripts/e2e.sh 12
|
||||
# Logs land in logs/log_<i>.log. Clean with scripts/clean-up.sh
|
||||
```
|
||||
|
||||
Run deterministic tests (require a release-built server in `sync-server/target/release/sync_server` — they spawn it themselves):
|
||||
|
||||
```sh
|
||||
cd sync-server && cargo build --release && cd ..
|
||||
cd frontend
|
||||
npm run dev # Start development mode (watches sync-client and obsidian-plugin)
|
||||
npm run build # Build all workspaces
|
||||
npm run test # Run all tests
|
||||
npm run lint # Lint and format TypeScript code
|
||||
npm run build -w sync-client -w deterministic-tests
|
||||
node deterministic-tests/dist/cli.js # all
|
||||
node deterministic-tests/dist/cli.js --filter=rename # subset
|
||||
node deterministic-tests/dist/cli.js --filter=… -j 4 # cap parallelism
|
||||
```
|
||||
|
||||
### Database Setup (Development)
|
||||
```bash
|
||||
Run a single sync-client unit test by file:
|
||||
|
||||
```sh
|
||||
cd frontend/sync-client && npx tsx --test 'src/**/sync-event-queue.test.ts'
|
||||
```
|
||||
|
||||
Server: dev runs from `sync-server/` against `config-e2e.yml`:
|
||||
|
||||
```sh
|
||||
cd sync-server
|
||||
cargo run config-e2e.yml # dev
|
||||
cargo build --release # used by both e2e harnesses
|
||||
cargo test # unit + ts-rs binding export tests
|
||||
```
|
||||
|
||||
Frontend dev (sync-client + obsidian-plugin watch in parallel):
|
||||
|
||||
```sh
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
Regenerate TS bindings from Rust types (touches `frontend/{sync-client,history-ui}/src/.../types/`):
|
||||
|
||||
```sh
|
||||
scripts/update-api-types.sh
|
||||
```
|
||||
|
||||
## SQLite / sqlx
|
||||
|
||||
The server uses `sqlx::query!` macros that need a prepared `.sqlx` cache to compile offline. Touching any SQL means regenerating it:
|
||||
|
||||
```sh
|
||||
cd sync-server
|
||||
sqlx database create --database-url sqlite://db.sqlite3
|
||||
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||
cargo sqlx prepare --workspace
|
||||
```
|
||||
|
||||
### Initial Setup
|
||||
```bash
|
||||
# Install required cargo tools
|
||||
cargo install sqlx-cli cargo-machete cargo-edit
|
||||
New migrations: `sqlx migrate add --source src/app_state/database/migrations <name>`.
|
||||
|
||||
## Sync engine architecture
|
||||
|
||||
Read `frontend/sync-client/src/sync-operations/` to follow the sync engine; the rest of `sync-client` is plumbing (filesystem ops, persistence, services, telemetry).
|
||||
|
||||
The engine is **two independent loops with separate invariants**:
|
||||
|
||||
- **Wire loop** (`syncer.ts`) — drains the single-consumer FIFO queue. HTTP and WS handlers update record fields (`remoteRelativePath`, `parentVersionId`, `remoteHash`) and write content to the file at `record.localPath`. They never move files for path placement.
|
||||
- **Path reconciler** (`reconciler.ts`) — runs after every drained event. Best-effort pass that moves files to make `localPath === remoteRelativePath`. The move graph is topologically sorted; cycles are resolved by reading every file in the cycle into memory and writing each back to its new slot (no tmp files). Records with pending local events are skipped on each pass — the reconciler operates only on settled records. Failures (slot occupied by an untracked file, etc.) are silent skips; the next pass retries.
|
||||
|
||||
**`SyncEventQueue`** (`sync-event-queue.ts`) holds:
|
||||
|
||||
- `byDocId: Map<DocumentId, DocumentRecord>` — primary record store.
|
||||
- `byLocalPath: Map<RelativePath, DocumentRecord>` — derived index for path lookups, maintained at every mutation point.
|
||||
- `events: SyncEvent[]` — pending wire ops in FIFO drain order.
|
||||
|
||||
```ts
|
||||
DocumentRecord = {
|
||||
documentId,
|
||||
parentVersionId,
|
||||
remoteHash?,
|
||||
remoteRelativePath,
|
||||
localPath: RelativePath | undefined
|
||||
}
|
||||
```
|
||||
|
||||
### Scripts
|
||||
- `scripts/check.sh`: Full CI check (builds, lints, tests both server and frontend)
|
||||
- `scripts/check.sh --fix`: Same as above but auto-fixes linting and formatting issues
|
||||
- `scripts/e2e.sh`: End-to-end testing
|
||||
- `scripts/clean-up.sh`: Clean logs and database files
|
||||
- `scripts/bump-version.sh patch`: Publish new version
|
||||
- `scripts/update-api-types.sh`: Update TypeScript bindings from Rust types
|
||||
`localPath === undefined` means the doc has no local file yet — typically a remote create whose target slot was occupied at receive time; the reconciler will fetch and place when the slot frees (the bytes wait in `pendingPlacementContent`).
|
||||
|
||||
## Code Structure
|
||||
Local FS events from the watcher update `localPath` synchronously at enqueue time via `setLocalPath` / `upsertRecord`. The wire loop never updates it for path placement; only the reconciler does. A user rename onto a tracked slot enqueues a `LocalDelete` for the displaced doc (the OS rename clobbered its content) and clears that doc's `localPath`.
|
||||
|
||||
### Workspace Configuration
|
||||
The frontend uses npm workspaces with four packages:
|
||||
- `sync-client`: Core synchronization logic
|
||||
- `obsidian-plugin`: Obsidian-specific integration
|
||||
- `test-client`: Testing utilities
|
||||
- `local-client-cli`: Standalone CLI for VaultLink sync client
|
||||
**Pending creates** use a `Promise<DocumentId>` chain to serialize dependent ops (`LocalUpdate`, `LocalDelete`) behind the still-in-flight `LocalCreate`. `resolveCreate` resolves the promise once the server returns a docId, and `replacePendingDocumentId` swaps the resolved id across already-queued events. `findLatestCreateForPath` is the lookup the watcher uses to attach dependents; `updatePendingCreatePath` rewrites a pending create's `event.path` in place when the user renames the file before its create has acked.
|
||||
|
||||
### Type Generation
|
||||
Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/bindings/` and used by frontend packages.
|
||||
**Watermark.** `lastSeenUpdateId` uses a `MinCovered` (a contiguous-prefix tracker over a stream of integers): we only advance the published min when the next consecutive id has been processed, so out-of-order RemoteChange ids don't fool the WebSocket handshake into requesting a too-recent catch-up.
|
||||
|
||||
### Key Files
|
||||
- `sync-server/src/`: Rust server implementation with WebSocket handlers
|
||||
- `frontend/sync-client/src/sync-client.ts`: Main sync client entry point
|
||||
- `frontend/obsidian-plugin/src/vault-link-plugin.ts`: Main Obsidian plugin class
|
||||
- `frontend/sync-client/src/services/sync-service.ts`: Core synchronization logic
|
||||
**Server catch-up.** The server's WS handshake replays events newer than the client's `last_seen_vault_update_id` from the `latest_document_versions` view (one row per doc, the latest). On those replayed rows `is_new_file` means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`), not "this row is the doc's first version" — necessary because the catch-up only carries the latest version; if a doc was created and updated past the watermark, the client never sees its create otherwise.
|
||||
|
||||
## Testing
|
||||
## Edge-case patterns the sync engine has to survive
|
||||
|
||||
### Running Tests
|
||||
- Server: `cargo test --verbose`
|
||||
- Frontend: `npm run test` (runs Jest across all workspaces)
|
||||
- E2E: `scripts/e2e.sh`
|
||||
The two-loop split defuses most of the old race catalogue (slot-collision stashes, conflict-uuid divergence, `MoveOnConflict.NEW`/`EXISTING` policy choices) by separating wire transport from path placement. What's left:
|
||||
|
||||
### Test Structure
|
||||
- Rust: Unit tests alongside source files
|
||||
- TypeScript: `.test.ts` files using Jest
|
||||
- E2E: Uses test-client to simulate multiple concurrent users
|
||||
**Pending-create docId is a `Promise`, not a string, until the create acks.** Any `LocalUpdate` / `LocalDelete` queued behind a still-in-flight `LocalCreate` carries the create's `resolvers.promise` as its `documentId`. `replacePendingDocumentId` swaps the resolved id across queued events when the create resolves; `===` comparisons against the resolved string elsewhere will silently fail until that swap runs. Anything that walks `events[]` looking for a docId match must either run after the swap or be tolerant of `Promise`-typed ids.
|
||||
|
||||
## Code Style
|
||||
**`processCreate` reads `event.path` live, not `event.originalPath`.** The watcher rewrites `event.path` in place via `updatePendingCreatePath` when the user renames a pending-create file. `originalPath` was removed from `LocalCreate` events specifically because reading it would send the stale pre-rename path to the server.
|
||||
|
||||
### Rust
|
||||
- Uses extensive Clippy lints (see Cargo.toml)
|
||||
- Follows pedantic linting rules
|
||||
- Forbids unsafe code
|
||||
- Uses cargo fmt with default settings
|
||||
**`record.localPath` mutates in place across awaits.** When the watcher renames a doc while a drain handler is awaiting an HTTP roundtrip, the queue mutates the in-flight event's record so subsequent reads see the new path. Snapshotting `record.localPath` into a local at function entry and using it after an `await` reads/writes a now-vacated slot. Read `record.localPath` live; only snapshot for the deliberate "did it change while I was awaiting" comparison.
|
||||
|
||||
### TypeScript
|
||||
- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings
|
||||
- ESLint with unused imports plugin
|
||||
- Consistent across all three frontend packages
|
||||
**Reconciler-defer is the wire-loop's contract with the reconciler.** The reconciler skips records where `hasPendingLocalEventsForDocumentId` returns true. Wire-loop handlers can therefore freely write `remoteRelativePath` to whatever the server returned — even if it disagrees with `localPath` — knowing the reconciler won't move the file out from under a queued user rename.
|
||||
|
||||
**Watermark advancement is load-bearing both ways.** Branches that _skip_ a remote event without advancing `lastSeenUpdateId` create permanent gaps that re-deliver forever. Branches that _advance_ without applying the content lose data: the server has no further event to re-deliver, the catch-up only carries the latest version, and any state in between is gone. Don't advance unless the event was actually applied (or deliberately discarded after weighing both halves).
|
||||
|
||||
**`isNewFile` semantics differ between catch-up and real-time.** On WS handshake replay it means _new to this client_ (`creation_vault_update_id > last_seen_vault_update_id`); on real-time broadcasts it means _this version is the create_ (`creation_vault_update_id == vault_update_id`). A handler that decides based on one interpretation will be wrong on the other channel; reasoning about fetch-and-treat-as-new vs. ignore needs to know which channel delivered the event.
|
||||
|
||||
**Pause / disable-sync mid-flight** is the one race the new model doesn't structurally fix. An HTTP that committed server-side but whose response was discarded leaves the server holding a doc the client has no record of. Resume → offline scan → server-side dedupe handles it (the server merges the duplicate create into the existing doc), but if the merge produces a deconflict, the client picks up an extra file. Out of scope for the two-loop split.
|
||||
|
||||
**Cycle reconciliation uses in-memory content swap.** When the move graph contains a cycle, the reconciler reads every file in the cycle into memory and writes each back to its new slot, with no tmp files. A write-ahead marker at `.vaultlink/swap-<uuid>.json` lists each leg; on startup the reconciler reads the marker, hashes each `from` to determine which legs ran, and replays the rest. The `.vaultlink/**` glob is hard-coded as an internal ignore pattern so swap markers don't get sync'd.
|
||||
|
||||
## Two complementary E2E harnesses
|
||||
|
||||
- **`test-client` (fuzz):** random ops across N parallel processes for many minutes. Used by `scripts/e2e.sh`. Catches bugs nobody thought to write a test for, but reproductions are noisy.
|
||||
- **`deterministic-tests`:** scripted scenarios with an in-memory FS pinned to a real server. Used to _capture_ a fuzz-discovered bug as a minimal repro before fixing it. See `frontend/deterministic-tests/README.md` for the step grammar (`pause-server`, `pause-websocket`, `barrier`, `assert-consistent`, etc.).
|
||||
|
||||
When a fuzz failure surfaces, the workflow is: root-cause from logs → write a deterministic test that fails on the bug → fix → confirm both the deterministic test and `e2e.sh` pass.
|
||||
|
||||
## Style
|
||||
|
||||
- TS: 4-space indent, no tabs, LF, prettier (`trailingComma: "none"`). YAML/MD use 2-space indent.
|
||||
- Rust: `rustfmt.toml` enforces 4-space spaces, LF.
|
||||
- Lint: ESLint for TS, Clippy for Rust, `cargo machete` for unused deps. All wired into `scripts/check.sh`.
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@
|
|||
|
||||
## 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`
|
||||
- `nvm install 22`
|
||||
- `nvm use 22`
|
||||
- Optionally set the system-wide default: `nvm alias default 22`
|
||||
- `nvm install 25`
|
||||
- `nvm use 25`
|
||||
- Optionally, set the system-wide default: `nvm alias default 25`
|
||||
|
||||
### Set up Rust
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,7 @@
|
|||
"version": "0.2",
|
||||
"language": "en-GB",
|
||||
"dictionaries": ["en-gb"],
|
||||
"ignorePaths": [
|
||||
"node_modules",
|
||||
".vitepress/dist",
|
||||
".vitepress/cache",
|
||||
"package-lock.json"
|
||||
],
|
||||
"ignorePaths": ["node_modules", ".vitepress/dist", ".vitepress/cache", "package-lock.json"],
|
||||
"words": [
|
||||
"VaultLink",
|
||||
"Obsidian",
|
||||
|
|
|
|||
|
|
@ -361,11 +361,11 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "upload_file",
|
||||
"path": "notes/example.md",
|
||||
"content": "File content here...",
|
||||
"base_version": 10,
|
||||
"timestamp": "2024-01-01T12:00:00Z"
|
||||
"type": "upload_file",
|
||||
"path": "notes/example.md",
|
||||
"content": "File content here...",
|
||||
"base_version": 10,
|
||||
"timestamp": "2024-01-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -373,8 +373,8 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "download_file",
|
||||
"path": "notes/example.md"
|
||||
"type": "download_file",
|
||||
"path": "notes/example.md"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -382,8 +382,8 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "delete_file",
|
||||
"path": "notes/old.md"
|
||||
"type": "delete_file",
|
||||
"path": "notes/old.md"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -391,8 +391,8 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "list_files",
|
||||
"since_version": 0
|
||||
"type": "list_files",
|
||||
"since_version": 0
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -402,11 +402,11 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "file_updated",
|
||||
"path": "notes/example.md",
|
||||
"version": 11,
|
||||
"size": 1024,
|
||||
"hash": "abc123..."
|
||||
"type": "file_updated",
|
||||
"path": "notes/example.md",
|
||||
"version": 11,
|
||||
"size": 1024,
|
||||
"hash": "abc123..."
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -414,10 +414,10 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "file_content",
|
||||
"path": "notes/example.md",
|
||||
"content": "Updated content...",
|
||||
"version": 11
|
||||
"type": "file_content",
|
||||
"path": "notes/example.md",
|
||||
"content": "Updated content...",
|
||||
"version": 11
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -425,9 +425,9 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "file_deleted",
|
||||
"path": "notes/old.md",
|
||||
"version": 12
|
||||
"type": "file_deleted",
|
||||
"path": "notes/old.md",
|
||||
"version": 12
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -435,9 +435,9 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "sync_complete",
|
||||
"total_files": 150,
|
||||
"current_version": 200
|
||||
"type": "sync_complete",
|
||||
"total_files": 150,
|
||||
"current_version": 200
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -445,9 +445,9 @@ VALUES (?, ?, ?);
|
|||
|
||||
```json
|
||||
{
|
||||
"type": "error",
|
||||
"message": "File too large",
|
||||
"code": "FILE_TOO_LARGE"
|
||||
"type": "error",
|
||||
"message": "File too large",
|
||||
"code": "FILE_TOO_LARGE"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ Central authority for synchronisation. Rust + Axum framework.
|
|||
|
||||
**Technology**:
|
||||
|
||||
- **Language**: Rust 1.89+
|
||||
- **Language**: Rust 1.92+
|
||||
- **Framework**: Axum (async web framework)
|
||||
- **Database**: SQLite with SQLx
|
||||
- **Protocol**: WebSockets for real-time communication
|
||||
|
|
|
|||
|
|
@ -243,9 +243,9 @@ users:
|
|||
2. Client sends authentication message:
|
||||
```json
|
||||
{
|
||||
"type": "auth",
|
||||
"token": "user-token",
|
||||
"vault": "vault-name"
|
||||
"type": "auth",
|
||||
"token": "user-token",
|
||||
"vault": "vault-name"
|
||||
}
|
||||
```
|
||||
3. Server validates:
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ chmod +x sync_server-linux-x86_64
|
|||
|
||||
### Build from Source
|
||||
|
||||
Requirements: Rust 1.89.0+, SQLite development headers, SQLx CLI
|
||||
Requirements: Rust 1.92.0+, SQLite development headers, SQLx CLI
|
||||
|
||||
```bash
|
||||
# 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
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 --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"test": "echo 'no tests yet'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
78
frontend/history-ui/src/App.svelte
Normal file
78
frontend/history-ui/src/App.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<script lang="ts">
|
||||
import { auth, nav, toasts } from "./lib/stores.svelte";
|
||||
import { listVaults } from "./lib/api";
|
||||
import Login from "./components/Login.svelte";
|
||||
import VaultPicker from "./components/VaultPicker.svelte";
|
||||
import Dashboard from "./components/Dashboard.svelte";
|
||||
import ToastContainer from "./components/ToastContainer.svelte";
|
||||
|
||||
let restoring = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
const saved = auth.tryRestore();
|
||||
if (!saved) {
|
||||
restoring = false;
|
||||
return;
|
||||
}
|
||||
listVaults(saved.token)
|
||||
.then((response) => {
|
||||
auth.authenticate(
|
||||
saved.token,
|
||||
response.userName,
|
||||
response.vaults
|
||||
);
|
||||
if (
|
||||
saved.vaultId &&
|
||||
response.vaults.some(
|
||||
(v) => v.name === saved.vaultId
|
||||
)
|
||||
) {
|
||||
auth.selectVault(saved.vaultId);
|
||||
}
|
||||
restoring = false;
|
||||
})
|
||||
.catch(() => {
|
||||
restoring = false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if restoring}
|
||||
<div class="loading-screen">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
{:else if !auth.token}
|
||||
<Login />
|
||||
{:else if !auth.isAuthenticated}
|
||||
<VaultPicker />
|
||||
{: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/view-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>
|
||||
508
frontend/history-ui/src/components/Dashboard.svelte
Normal file
508
frontend/history-ui/src/components/Dashboard.svelte
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
auth,
|
||||
nav,
|
||||
toasts,
|
||||
buildTree,
|
||||
enrichVersions,
|
||||
relativeTime,
|
||||
formatBytes,
|
||||
type View
|
||||
} from "../lib/stores.svelte";
|
||||
import type { DocumentVersionWithoutContent } from "../lib/types/DocumentVersionWithoutContent";
|
||||
import type { VaultHistoryResponse } from "../lib/types/VaultHistoryResponse";
|
||||
import type { VersionEvent, TreeNode } from "../lib/view-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;
|
||||
|
||||
api.ping().then((ping) => {
|
||||
auth.serverVersion = ping.serverVersion;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await api.fetchLatestDocuments();
|
||||
latestDocuments = response.latestDocuments;
|
||||
maxUpdateId = Number(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>
|
||||
729
frontend/history-ui/src/components/DocumentDetail.svelte
Normal file
729
frontend/history-ui/src/components/DocumentDetail.svelte
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
auth,
|
||||
toasts,
|
||||
relativeTime,
|
||||
absoluteTime,
|
||||
formatBytes,
|
||||
inferAction,
|
||||
isTextFile,
|
||||
isImageFile,
|
||||
fileExtension
|
||||
} from "../lib/stores.svelte";
|
||||
import type { DocumentVersionWithoutContent } from "../lib/types/DocumentVersionWithoutContent";
|
||||
import type { DocumentVersion } from "../lib/types/DocumentVersion";
|
||||
import type { ActionType } from "../lib/view-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 || !latest) return;
|
||||
restoring = true;
|
||||
try {
|
||||
// Restore = re-submit the target version's bytes at its path
|
||||
// as if it were a fresh edit. `update_document` short-circuits
|
||||
// on `is_deleted`, so resurrecting a deleted doc has to go
|
||||
// through `create_document`; a live doc takes the normal
|
||||
// update path with the current latest as its parent.
|
||||
const bytes = await api.fetchDocumentVersionContent(
|
||||
documentId,
|
||||
restoreTarget.vaultUpdateId
|
||||
);
|
||||
if (latest.isDeleted) {
|
||||
await api.createDocument(
|
||||
latest.vaultUpdateId,
|
||||
restoreTarget.relativePath,
|
||||
bytes
|
||||
);
|
||||
} else {
|
||||
await api.updateBinaryDocument(
|
||||
documentId,
|
||||
latest.vaultUpdateId,
|
||||
restoreTarget.relativePath,
|
||||
bytes
|
||||
);
|
||||
}
|
||||
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/view-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>
|
||||
144
frontend/history-ui/src/components/Header.svelte
Normal file
144
frontend/history-ui/src/components/Header.svelte
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<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>
|
||||
{#if auth.availableVaults.length > 1}
|
||||
<button
|
||||
class="header-btn"
|
||||
onclick={() => auth.deselectVault()}
|
||||
title="Switch vault"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="header-btn"
|
||||
onclick={() => auth.logout()}
|
||||
title="Sign out"
|
||||
>
|
||||
<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>
|
||||
176
frontend/history-ui/src/components/Login.svelte
Normal file
176
frontend/history-ui/src/components/Login.svelte
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<script lang="ts">
|
||||
import { auth } from "../lib/stores.svelte";
|
||||
import { listVaults } from "../lib/api";
|
||||
|
||||
let token = $state("");
|
||||
let error = $state("");
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!token.trim()) {
|
||||
error = "Token is required.";
|
||||
return;
|
||||
}
|
||||
error = "";
|
||||
loading = true;
|
||||
try {
|
||||
const response = await listVaults(token.trim());
|
||||
auth.authenticate(
|
||||
token.trim(),
|
||||
response.userName,
|
||||
response.vaults
|
||||
);
|
||||
} catch {
|
||||
error = "Authentication failed. Check your token.";
|
||||
} 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>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/DocumentVersionWithoutContent";
|
||||
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>
|
||||
198
frontend/history-ui/src/components/VaultPicker.svelte
Normal file
198
frontend/history-ui/src/components/VaultPicker.svelte
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
<script lang="ts">
|
||||
import { auth } from "../lib/stores.svelte";
|
||||
import { relativeTime } from "../lib/stores.svelte";
|
||||
import type { VaultInfo } from "../lib/types/VaultInfo";
|
||||
|
||||
function select(vault: VaultInfo) {
|
||||
auth.selectVault(vault.name);
|
||||
}
|
||||
|
||||
function formatStats(vault: VaultInfo): string {
|
||||
const docs = vault.documentCount === 1
|
||||
? "1 document"
|
||||
: `${vault.documentCount} documents`;
|
||||
if (!vault.createdAt) return docs;
|
||||
return `${docs} · created ${relativeTime(vault.createdAt)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="picker-page">
|
||||
<div class="picker-card">
|
||||
<div class="picker-header">
|
||||
<div class="logo">
|
||||
<svg width="28" height="28" 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>
|
||||
<div>
|
||||
<h1>Select a vault</h1>
|
||||
<p class="user-info">
|
||||
Signed in as <strong>{auth.userName}</strong>
|
||||
<button class="logout-link" onclick={() => auth.logout()}>Sign out</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if auth.availableVaults.length === 0}
|
||||
<div class="empty">
|
||||
<p>No vaults found</p>
|
||||
<p class="empty-hint">
|
||||
Vaults are created when a sync client first connects.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="vault-list">
|
||||
{#each auth.availableVaults as vault}
|
||||
<li>
|
||||
<button class="vault-item" onclick={() => select(vault)}>
|
||||
<svg class="vault-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<div class="vault-details">
|
||||
<span class="vault-name">{vault.name}</span>
|
||||
<span class="vault-stats">{formatStats(vault)}</span>
|
||||
</div>
|
||||
<svg class="vault-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.picker-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.picker-card {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.picker-header {
|
||||
padding: 32px 32px 24px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.logo svg {
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.user-info strong {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.logout-link {
|
||||
color: var(--text-subtle);
|
||||
font-size: 13px;
|
||||
text-decoration: underline;
|
||||
margin-left: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.logout-link:hover {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.vault-list {
|
||||
list-style: none;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.vault-list li + li {
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.vault-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 14px 24px;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.vault-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.vault-icon {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vault-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vault-name {
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vault-stats {
|
||||
font-size: 12px;
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.vault-arrow {
|
||||
color: var(--text-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-subtle);
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
146
frontend/history-ui/src/lib/api.ts
Normal file
146
frontend/history-ui/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
|
||||
import type { DocumentVersion } from "./types/DocumentVersion";
|
||||
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
||||
import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse";
|
||||
import type { ListVaultsResponse } from "./types/ListVaultsResponse";
|
||||
import type { PingResponse } from "./types/PingResponse";
|
||||
import type { VaultHistoryResponse } from "./types/VaultHistoryResponse";
|
||||
|
||||
async function fetchJsonWithToken<T>(
|
||||
path: string,
|
||||
token: string,
|
||||
init?: RequestInit
|
||||
): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"device-id": "history-ui",
|
||||
...init?.headers
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${body}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listVaults(token: string): Promise<ListVaultsResponse> {
|
||||
return fetchJsonWithToken("/vaults", token);
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
constructor(
|
||||
private vaultId: string,
|
||||
private token: string
|
||||
) {}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return `/vaults/${encodeURIComponent(this.vaultId)}`;
|
||||
}
|
||||
|
||||
private async fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return fetchJsonWithToken(path, this.token, init);
|
||||
}
|
||||
|
||||
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: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"device-id": "history-ui"
|
||||
}
|
||||
}
|
||||
);
|
||||
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}` : ""}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a new version of an existing (non-deleted) document. The
|
||||
* server treats this like any other edit — server-side merging,
|
||||
* path dedupe, and broadcast still apply. Used by the UI to restore
|
||||
* an old version by re-submitting its bytes on top of the latest.
|
||||
*/
|
||||
async updateBinaryDocument(
|
||||
documentId: string,
|
||||
parentVersionId: number,
|
||||
relativePath: string,
|
||||
content: ArrayBuffer
|
||||
): Promise<DocumentUpdateResponse> {
|
||||
const form = new FormData();
|
||||
form.append("parent_version_id", String(parentVersionId));
|
||||
form.append("relative_path", relativePath);
|
||||
form.append("content", new Blob([content]));
|
||||
return this.fetchJson(
|
||||
`${this.baseUrl}/documents/${documentId}/binary`,
|
||||
{ method: "PUT", body: form }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new document. Used by the UI to restore a deleted
|
||||
* document: `update_document` short-circuits on `is_deleted`, so
|
||||
* resurrection has to go through `create_document` — which detects
|
||||
* an existing doc at the same path, merges or dedupes as needed,
|
||||
* and returns the resulting version.
|
||||
*/
|
||||
async createDocument(
|
||||
lastSeenVaultUpdateId: number,
|
||||
relativePath: string,
|
||||
content: ArrayBuffer
|
||||
): Promise<DocumentUpdateResponse> {
|
||||
const form = new FormData();
|
||||
form.append("last_seen_vault_update_id", String(lastSeenVaultUpdateId));
|
||||
form.append("relative_path", relativePath);
|
||||
form.append("content", new Blob([content]));
|
||||
return this.fetchJson(`${this.baseUrl}/documents`, {
|
||||
method: "POST",
|
||||
body: form
|
||||
});
|
||||
}
|
||||
}
|
||||
290
frontend/history-ui/src/lib/stores.svelte.ts
Normal file
290
frontend/history-ui/src/lib/stores.svelte.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import { ApiClient } from "./api";
|
||||
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
||||
import type { VaultInfo } from "./types/VaultInfo";
|
||||
import type { VersionEvent, ActionType, TreeNode } from "./view-types";
|
||||
|
||||
class AuthStore {
|
||||
token = $state("");
|
||||
userName = $state("");
|
||||
vaultId = $state("");
|
||||
serverVersion = $state("");
|
||||
availableVaults = $state<VaultInfo[]>([]);
|
||||
isAuthenticated = $state(false);
|
||||
api = $state<ApiClient | null>(null);
|
||||
|
||||
authenticate(token: string, userName: string, vaults: VaultInfo[]) {
|
||||
this.token = token;
|
||||
this.userName = userName;
|
||||
this.availableVaults = vaults;
|
||||
sessionStorage.setItem("vaultlink_token", token);
|
||||
}
|
||||
|
||||
selectVault(vaultId: string) {
|
||||
this.vaultId = vaultId;
|
||||
this.isAuthenticated = true;
|
||||
this.api = new ApiClient(vaultId, this.token);
|
||||
sessionStorage.setItem("vaultlink_vault", vaultId);
|
||||
}
|
||||
|
||||
deselectVault() {
|
||||
this.vaultId = "";
|
||||
this.isAuthenticated = false;
|
||||
this.api = null;
|
||||
sessionStorage.removeItem("vaultlink_vault");
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.token = "";
|
||||
this.userName = "";
|
||||
this.vaultId = "";
|
||||
this.serverVersion = "";
|
||||
this.availableVaults = [];
|
||||
this.isAuthenticated = false;
|
||||
this.api = null;
|
||||
sessionStorage.removeItem("vaultlink_token");
|
||||
sessionStorage.removeItem("vaultlink_vault");
|
||||
}
|
||||
|
||||
tryRestore(): { token: string; vaultId?: string } | null {
|
||||
const token = sessionStorage.getItem("vaultlink_token");
|
||||
if (!token) return null;
|
||||
const vaultId = sessionStorage.getItem("vaultlink_vault") ?? undefined;
|
||||
return { token, vaultId };
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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));
|
||||
}
|
||||
8
frontend/history-ui/src/lib/types/ClientCursors.ts
Normal file
8
frontend/history-ui/src/lib/types/ClientCursors.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||
|
||||
export type ClientCursors = {
|
||||
userName: string;
|
||||
deviceId: string;
|
||||
documentsWithCursors: Array<DocumentWithCursors>;
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export interface UpdateDocumentVersion {
|
||||
parent_version_id: bigint;
|
||||
export type CreateDocumentVersion = {
|
||||
relative_path: string;
|
||||
content: number[];
|
||||
}
|
||||
last_seen_vault_update_id: number;
|
||||
content: Array<number>;
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentWithCursors } from "./DocumentWithCursors";
|
||||
|
||||
export type CursorPositionFromClient = {
|
||||
documentsWithCursors: Array<DocumentWithCursors>;
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ClientCursors } from "./ClientCursors";
|
||||
|
||||
export type CursorPositionFromServer = { clients: Array<ClientCursors> };
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export interface DeleteDocumentVersion {
|
||||
relativePath: string;
|
||||
}
|
||||
export type CursorSpan = { start: number; end: number };
|
||||
10
frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts
Normal file
10
frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentVersion } from "./DocumentVersion";
|
||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||
|
||||
/**
|
||||
* Response to a create/update document request.
|
||||
*/
|
||||
export type DocumentUpdateResponse =
|
||||
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
|
||||
| ({ type: "MergingUpdate" } & DocumentVersion);
|
||||
12
frontend/history-ui/src/lib/types/DocumentVersion.ts
Normal file
12
frontend/history-ui/src/lib/types/DocumentVersion.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DocumentVersion = {
|
||||
vaultUpdateId: number;
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
updatedDate: string;
|
||||
contentBase64: string;
|
||||
isDeleted: boolean;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type DocumentVersionWithoutContent = {
|
||||
vaultUpdateId: number;
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
updatedDate: string;
|
||||
isDeleted: boolean;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
contentSize: number;
|
||||
/**
|
||||
* True iff this is the first version of the document
|
||||
*/
|
||||
isNewFile: boolean;
|
||||
};
|
||||
9
frontend/history-ui/src/lib/types/DocumentWithCursors.ts
Normal file
9
frontend/history-ui/src/lib/types/DocumentWithCursors.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { CursorSpan } from "./CursorSpan";
|
||||
|
||||
export type DocumentWithCursors = {
|
||||
vaultUpdateId: number | null;
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
cursors: Array<CursorSpan>;
|
||||
};
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||
|
||||
/**
|
||||
* Response to a fetch latest documents request.
|
||||
*/
|
||||
export type FetchLatestDocumentsResponse = {
|
||||
latestDocuments: Array<DocumentVersionWithoutContent>;
|
||||
/**
|
||||
* The update ID of the latest document in the response.
|
||||
*/
|
||||
lastUpdateId: bigint;
|
||||
};
|
||||
11
frontend/history-ui/src/lib/types/ListVaultsResponse.ts
Normal file
11
frontend/history-ui/src/lib/types/ListVaultsResponse.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { VaultInfo } from "./VaultInfo";
|
||||
|
||||
/**
|
||||
* Response to listing vaults accessible to the authenticated user.
|
||||
*/
|
||||
export type ListVaultsResponse = {
|
||||
vaults: Array<VaultInfo>;
|
||||
hasMore: boolean;
|
||||
userName: string;
|
||||
};
|
||||
25
frontend/history-ui/src/lib/types/PingResponse.ts
Normal file
25
frontend/history-ui/src/lib/types/PingResponse.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Response to a ping request.
|
||||
*/
|
||||
export type PingResponse = {
|
||||
/**
|
||||
* Semantic version of the server.
|
||||
*/
|
||||
serverVersion: string;
|
||||
/**
|
||||
* Whether the client is authenticated based on the sent Authorization
|
||||
* header.
|
||||
*/
|
||||
isAuthenticated: boolean;
|
||||
/**
|
||||
* List of file extensions that are allowed to be merged.
|
||||
*/
|
||||
mergeableFileExtensions: Array<string>;
|
||||
/**
|
||||
* API version ensuring backwards & forwards compatibility between the client
|
||||
* and server.
|
||||
*/
|
||||
supportedApiVersion: number;
|
||||
};
|
||||
7
frontend/history-ui/src/lib/types/SerializedError.ts
Normal file
7
frontend/history-ui/src/lib/types/SerializedError.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type SerializedError = {
|
||||
errorType: string;
|
||||
message: string;
|
||||
causes: Array<string>;
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type UpdateTextDocumentVersion = {
|
||||
parentVersionId: number;
|
||||
relativePath: string | null;
|
||||
content: Array<number | string>;
|
||||
};
|
||||
10
frontend/history-ui/src/lib/types/VaultHistoryResponse.ts
Normal file
10
frontend/history-ui/src/lib/types/VaultHistoryResponse.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||
|
||||
/**
|
||||
* Response to a vault history request (paginated).
|
||||
*/
|
||||
export type VaultHistoryResponse = {
|
||||
versions: Array<DocumentVersionWithoutContent>;
|
||||
hasMore: boolean;
|
||||
};
|
||||
10
frontend/history-ui/src/lib/types/VaultInfo.ts
Normal file
10
frontend/history-ui/src/lib/types/VaultInfo.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Summary of a single vault returned by the list-vaults endpoint.
|
||||
*/
|
||||
export type VaultInfo = {
|
||||
name: string;
|
||||
documentCount: number;
|
||||
createdAt: string | null;
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { CursorPositionFromClient } from "./CursorPositionFromClient";
|
||||
import type { WebSocketHandshake } from "./WebSocketHandshake";
|
||||
|
||||
export type WebSocketClientMessage =
|
||||
| ({ type: "handshake" } & WebSocketHandshake)
|
||||
| ({ type: "cursorPositions" } & CursorPositionFromClient);
|
||||
7
frontend/history-ui/src/lib/types/WebSocketHandshake.ts
Normal file
7
frontend/history-ui/src/lib/types/WebSocketHandshake.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type WebSocketHandshake = {
|
||||
token: string;
|
||||
deviceId: string;
|
||||
lastSeenVaultUpdateId: number | null;
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { CursorPositionFromServer } from "./CursorPositionFromServer";
|
||||
import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate";
|
||||
|
||||
export type WebSocketServerMessage =
|
||||
| ({ type: "vaultUpdate" } & WebSocketVaultUpdate)
|
||||
| ({ type: "cursorPositions" } & CursorPositionFromServer);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||
|
||||
export type WebSocketVaultUpdate = { document: DocumentVersionWithoutContent };
|
||||
22
frontend/history-ui/src/lib/view-types.ts
Normal file
22
frontend/history-ui/src/lib/view-types.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
||||
|
||||
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:3010"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -14,19 +14,17 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"byte-base64": "^1.1.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"p-queue": "^8.1.0",
|
||||
"minimatch": "^10.1.1",
|
||||
"p-queue": "^9.0.1",
|
||||
"reconcile-text": "^0.8.0",
|
||||
"uuid": "^13.0.0",
|
||||
"@types/node": "^24.8.1",
|
||||
"ts-loader": "^9.5.2",
|
||||
"@types/node": "^25.0.2",
|
||||
"ts-loader": "^9.5.4",
|
||||
"tslib": "2.8.1",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "5.8.3",
|
||||
"webpack": "^5.99.9",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"webpack": "^5.103.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-merge": "^6.0.1",
|
||||
"@sentry/browser": "^10.8.0",
|
||||
"ws": "^8.18.3"
|
||||
"@sentry/browser": "^10.30.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60;
|
||||
export const DIFF_CACHE_SIZE_MB = 2;
|
||||
export const MAX_LOG_MESSAGE_COUNT = 100000;
|
||||
export const MAX_HISTORY_ENTRY_COUNT = 5000;
|
||||
export const SUPPORTED_API_VERSION = 2;
|
||||
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10;
|
||||
export const SUPPORTED_API_VERSION = 3;
|
||||
export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS = 10;
|
||||
export const WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS = 10;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
export class FileAlreadyExistsError extends Error {
|
||||
public constructor(
|
||||
message: string,
|
||||
public readonly filePath: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FileAlreadyExistsError";
|
||||
}
|
||||
}
|
||||
9
frontend/sync-client/src/errors/http-client-error.ts
Normal file
9
frontend/sync-client/src/errors/http-client-error.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export class HttpClientError extends Error {
|
||||
public constructor(
|
||||
public readonly statusCode: number,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HttpClientError";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
import { describe, it } from "node:test";
|
||||
import type {
|
||||
Database,
|
||||
DocumentRecord,
|
||||
RelativePath
|
||||
} from "../persistence/database";
|
||||
import assert from "node:assert/strict";
|
||||
import type { RelativePath } from "../sync-operations/types";
|
||||
import { FileOperations } from "./file-operations";
|
||||
import { Logger } from "../tracing/logger";
|
||||
import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
|
||||
import type { FileSystemOperations } from "./filesystem-operations";
|
||||
import type { TextWithCursors } from "reconcile-text";
|
||||
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
||||
import { ExpectedFsEvents } from "../sync-operations/expected-fs-events";
|
||||
import { FileAlreadyExistsError } from "../errors/file-already-exists-error";
|
||||
|
||||
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
||||
public async getConfig(): Promise<ServerConfigData> {
|
||||
|
|
@ -21,29 +20,13 @@ class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
|||
}
|
||||
}
|
||||
|
||||
class MockDatabase implements Partial<Database> {
|
||||
public getLatestDocumentByRelativePath(
|
||||
_find: RelativePath
|
||||
): DocumentRecord | undefined {
|
||||
// no-op
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public move(
|
||||
_oldRelativePath: RelativePath,
|
||||
_newRelativePath: RelativePath
|
||||
): void {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
class FakeFileSystemOperations implements FileSystemOperations {
|
||||
public readonly names = new Set<string>();
|
||||
|
||||
public async listFilesRecursively(
|
||||
_root: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
return ["file.md"];
|
||||
return Array.from(this.names);
|
||||
}
|
||||
public async read(_path: RelativePath): Promise<Uint8Array> {
|
||||
throw new Error("Method not implemented.");
|
||||
|
|
@ -63,17 +46,14 @@ class FakeFileSystemOperations implements FileSystemOperations {
|
|||
public async getFileSize(_path: RelativePath): Promise<number> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async getModificationTime(_path: RelativePath): Promise<Date> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async exists(path: RelativePath): Promise<boolean> {
|
||||
return this.names.has(path);
|
||||
}
|
||||
public async createDirectory(_path: RelativePath): Promise<void> {
|
||||
// this is called but irrelevant for this mock
|
||||
// no-op for the in-memory fake; we only track files
|
||||
}
|
||||
public async delete(_path: RelativePath): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
public async delete(path: RelativePath): Promise<void> {
|
||||
this.names.delete(path);
|
||||
}
|
||||
public async rename(
|
||||
oldPath: RelativePath,
|
||||
|
|
@ -84,152 +64,92 @@ class FakeFileSystemOperations implements FileSystemOperations {
|
|||
}
|
||||
}
|
||||
|
||||
function makeOps(): {
|
||||
fs: FakeFileSystemOperations;
|
||||
ops: FileOperations;
|
||||
} {
|
||||
const fs = new FakeFileSystemOperations();
|
||||
const ops = new FileOperations(
|
||||
new Logger(),
|
||||
fs,
|
||||
new MockServerConfig() as ServerConfig, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
new ExpectedFsEvents()
|
||||
);
|
||||
return { fs, ops };
|
||||
}
|
||||
|
||||
describe("File operations", () => {
|
||||
it("should deconflict renames", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
it("create writes the file at the requested path", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await fileOperations.create("a", new Uint8Array());
|
||||
assertSetContainsExactly(fileSystemOperations.names, "a");
|
||||
await fileOperations.move("a", "b");
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b");
|
||||
const result = await ops.create("a", new Uint8Array());
|
||||
|
||||
await fileOperations.create("c", new Uint8Array());
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b", "c");
|
||||
|
||||
await fileOperations.move("c", "b");
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)");
|
||||
|
||||
await fileOperations.create("c", new Uint8Array());
|
||||
await fileOperations.move("c", "b");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b",
|
||||
"b (1)",
|
||||
"b (2)"
|
||||
);
|
||||
assertSetContainsExactly(fs.names, "a");
|
||||
assert.equal(result.actualPath, "a");
|
||||
});
|
||||
|
||||
it("should deconflict renames with file extension", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
it("create throws FileAlreadyExistsError when the path is occupied", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await ops.create("note.md", new Uint8Array());
|
||||
await assert.rejects(
|
||||
ops.create("note.md", new Uint8Array()),
|
||||
FileAlreadyExistsError
|
||||
);
|
||||
|
||||
await fileOperations.create("b.md", new Uint8Array());
|
||||
await fileOperations.create("c.md", new Uint8Array());
|
||||
await fileOperations.move("c.md", "b.md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md"
|
||||
);
|
||||
|
||||
await fileOperations.create("d.md", new Uint8Array());
|
||||
await fileOperations.move("d.md", "b.md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md",
|
||||
"b (2).md"
|
||||
);
|
||||
|
||||
await fileOperations.create("file-23.md", new Uint8Array());
|
||||
await fileOperations.create("file-23 (1).md", new Uint8Array());
|
||||
await fileOperations.move("file-23.md", "file-23 (1).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md",
|
||||
"b (2).md",
|
||||
"file-23 (1).md",
|
||||
"file-23 (2).md"
|
||||
);
|
||||
// The original file is left intact and no other entries appeared.
|
||||
assertSetContainsExactly(fs.names, "note.md");
|
||||
});
|
||||
|
||||
it("should deconflict renames with paths", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
it("move to an empty target just renames the file", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await fileOperations.create("a/b.c/d", new Uint8Array());
|
||||
await fileOperations.create("a/b.c/e", new Uint8Array());
|
||||
await fileOperations.move("a/b.c/d", "a/b.c/e");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"a/b.c/e",
|
||||
"a/b.c/e (1)"
|
||||
);
|
||||
await ops.create("a", new Uint8Array());
|
||||
assertSetContainsExactly(fs.names, "a");
|
||||
|
||||
const result = await ops.move("a", "b");
|
||||
assertSetContainsExactly(fs.names, "b");
|
||||
assert.equal(result.actualPath, "b");
|
||||
});
|
||||
|
||||
it("should continue deconfliction from existing number in filename", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
it("move with same source and target is a no-op", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await fileOperations.create("document (5).md", new Uint8Array());
|
||||
await fileOperations.create("other.md", new Uint8Array());
|
||||
await ops.create("a", new Uint8Array());
|
||||
const result = await ops.move("a", "a");
|
||||
|
||||
await fileOperations.move("other.md", "document (5).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"document (5).md",
|
||||
"document (6).md"
|
||||
);
|
||||
|
||||
await fileOperations.create("another.md", new Uint8Array());
|
||||
await fileOperations.move("another.md", "document (5).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"document (5).md",
|
||||
"document (6).md",
|
||||
"document (7).md"
|
||||
);
|
||||
assertSetContainsExactly(fs.names, "a");
|
||||
assert.equal(result.actualPath, "a");
|
||||
});
|
||||
|
||||
it("should handle dotfiles correctly", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
it("move throws FileAlreadyExistsError when the target is occupied", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await ops.create("source.md", new Uint8Array());
|
||||
await ops.create("dest.md", new Uint8Array());
|
||||
|
||||
await assert.rejects(
|
||||
ops.move("source.md", "dest.md"),
|
||||
FileAlreadyExistsError
|
||||
);
|
||||
|
||||
await fileOperations.create(".gitignore", new Uint8Array());
|
||||
await fileOperations.create("temp", new Uint8Array());
|
||||
await fileOperations.move("temp", ".gitignore");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
".gitignore",
|
||||
".gitignore (1)"
|
||||
);
|
||||
// Both files are left intact — no displacement happens.
|
||||
assertSetContainsExactly(fs.names, "source.md", "dest.md");
|
||||
});
|
||||
|
||||
await fileOperations.create(".config.json", new Uint8Array());
|
||||
await fileOperations.create("temp2", new Uint8Array());
|
||||
await fileOperations.move("temp2", ".config.json");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
".gitignore",
|
||||
".gitignore (1)",
|
||||
".config.json",
|
||||
".config (1).json"
|
||||
);
|
||||
it("create works for nested paths (parent-directory creation)", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await ops.create("a/b.c/d", new Uint8Array());
|
||||
assertSetContainsExactly(fs.names, "a/b.c/d");
|
||||
});
|
||||
|
||||
it("move works for nested target paths (parent-directory creation)", async () => {
|
||||
const { fs, ops } = makeOps();
|
||||
|
||||
await ops.create("source", new Uint8Array());
|
||||
await ops.move("source", "a/b.c/dest");
|
||||
|
||||
assertSetContainsExactly(fs.names, "a/b.c/dest");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,28 +1,40 @@
|
|||
import type { Logger } from "../tracing/logger";
|
||||
import type { FileSystemOperations } from "./filesystem-operations";
|
||||
import type { Database, RelativePath } from "../persistence/database";
|
||||
import type { RelativePath } from "../sync-operations/types";
|
||||
import { SafeFileSystemOperations } from "./safe-filesystem-operations";
|
||||
import type { TextWithCursors } from "reconcile-text";
|
||||
import { reconcile } from "reconcile-text";
|
||||
import { isFileTypeMergable } from "../utils/is-file-type-mergable";
|
||||
import { isBinary } from "../utils/is-binary";
|
||||
import type { ServerConfig } from "../services/server-config";
|
||||
import { FileNotFoundError } from "../errors/file-not-found-error";
|
||||
import { FileAlreadyExistsError } from "../errors/file-already-exists-error";
|
||||
import type { ExpectedFsEvents } from "../sync-operations/expected-fs-events";
|
||||
|
||||
/**
|
||||
* Outcome of a `move`/`create`. `actualPath` is where the file ended up;
|
||||
* with the conflict-path machinery removed it is always equal to the
|
||||
* requested path. The shape is preserved so callers don't all need to
|
||||
* change.
|
||||
*/
|
||||
export interface FileOpResult {
|
||||
actualPath: RelativePath;
|
||||
}
|
||||
|
||||
export class FileOperations {
|
||||
private static readonly PARENTHESES_REGEX = / \((?<count>\d+)\)$/;
|
||||
private readonly fs: SafeFileSystemOperations;
|
||||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly database: Database,
|
||||
fs: FileSystemOperations,
|
||||
private readonly serverConfig: ServerConfig,
|
||||
private readonly expectedFsEvents: ExpectedFsEvents,
|
||||
private readonly nativeLineEndings = "\n"
|
||||
) {
|
||||
this.fs = new SafeFileSystemOperations(fs, logger);
|
||||
}
|
||||
|
||||
private static getParentDirAndFile(
|
||||
private static getParentDirAndFileName(
|
||||
path: RelativePath
|
||||
): [RelativePath, RelativePath] {
|
||||
const pathParts = path.split("/");
|
||||
|
|
@ -45,43 +57,42 @@ export class FileOperations {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create a file at the specified path.
|
||||
*
|
||||
* If a file with the same name already exists, it is moved before creating the new one.
|
||||
* Parent directories are created if necessary.
|
||||
*/
|
||||
* Create a file at the specified path.
|
||||
*
|
||||
* Throws `FileAlreadyExistsError` if a file already lives at `path`.
|
||||
* Parent directories are created if necessary. The reconciler is the
|
||||
* only caller that places files now and pre-checks for conflicts;
|
||||
* the throw guards against a TOCTOU race rather than being a normal
|
||||
* code path.
|
||||
*/
|
||||
public async create(
|
||||
path: RelativePath,
|
||||
newContent: Uint8Array
|
||||
): Promise<void> {
|
||||
await this.ensureClearPath(path);
|
||||
return this.fs.write(path, this.toNativeLineEndings(newContent));
|
||||
}
|
||||
|
||||
public async ensureClearPath(path: RelativePath): Promise<void> {
|
||||
): Promise<FileOpResult> {
|
||||
if (await this.fs.exists(path)) {
|
||||
const deconflictedPath = await this.deconflictPath(path);
|
||||
try {
|
||||
this.logger.debug(
|
||||
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
|
||||
);
|
||||
|
||||
this.database.move(path, deconflictedPath);
|
||||
await this.fs.rename(path, deconflictedPath, true);
|
||||
} finally {
|
||||
this.fs.unlock(deconflictedPath);
|
||||
}
|
||||
} else {
|
||||
await this.createParentDirectories(path);
|
||||
throw new FileAlreadyExistsError(
|
||||
`Refusing to create '${path}': file already exists`,
|
||||
path
|
||||
);
|
||||
}
|
||||
await this.createParentDirectories(path);
|
||||
|
||||
this.expectedFsEvents.expectCreate(path);
|
||||
try {
|
||||
await this.fs.write(path, this.toNativeLineEndings(newContent));
|
||||
} catch (e) {
|
||||
this.expectedFsEvents.unexpectCreate(path);
|
||||
throw e;
|
||||
}
|
||||
return { actualPath: path };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the file at the given path.
|
||||
*
|
||||
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
|
||||
* Does not recreate the file if it no longer exists, returning an empty array instead.
|
||||
*/
|
||||
* Update the file at the given path.
|
||||
*
|
||||
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
|
||||
* Does not recreate the file if it no longer exists, returning an empty array instead.
|
||||
*/
|
||||
public async write(
|
||||
path: RelativePath,
|
||||
expectedContent: Uint8Array,
|
||||
|
|
@ -94,58 +105,96 @@ export class FileOperations {
|
|||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isFileTypeMergable(
|
||||
path,
|
||||
(await this.serverConfig.getConfig()).mergeableFileExtensions
|
||||
) ||
|
||||
isBinary(expectedContent) ||
|
||||
isBinary(newContent)
|
||||
) {
|
||||
this.logger.debug(
|
||||
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
|
||||
);
|
||||
await this.fs.write(
|
||||
path,
|
||||
// `newContent` might not be binary so we still have to ensure the line endings are correct
|
||||
this.toNativeLineEndings(newContent)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings
|
||||
const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings
|
||||
|
||||
await this.fs.atomicUpdateText(
|
||||
path,
|
||||
({ text, cursors }: TextWithCursors): TextWithCursors => {
|
||||
// Single-source the expectation registration: register exactly once
|
||||
// per call, and unexpect from the catch if the underlying fs op
|
||||
// throws (FileNotFoundError or otherwise). The previous shape
|
||||
// registered inside each branch and let the catch swallow
|
||||
// FileNotFoundError, leaking the expectation into the map.
|
||||
this.expectedFsEvents.expectUpdate(path);
|
||||
try {
|
||||
if (
|
||||
!isFileTypeMergable(
|
||||
path,
|
||||
(await this.serverConfig.getConfig())
|
||||
.mergeableFileExtensions
|
||||
) ||
|
||||
isBinary(expectedContent) ||
|
||||
isBinary(newContent)
|
||||
) {
|
||||
this.logger.debug(
|
||||
`Performing a 3-way merge for ${path} with the expected content`
|
||||
`The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it`
|
||||
);
|
||||
|
||||
text = text.replaceAll(this.nativeLineEndings, "\n");
|
||||
const merged = reconcile(
|
||||
expectedText,
|
||||
{ text, cursors },
|
||||
newText
|
||||
await this.fs.write(
|
||||
path,
|
||||
// `newContent` might not be binary so we still have to ensure the line endings are correct
|
||||
this.toNativeLineEndings(newContent)
|
||||
);
|
||||
|
||||
const resultText = merged.text.replaceAll(
|
||||
"\n",
|
||||
this.nativeLineEndings
|
||||
);
|
||||
|
||||
return {
|
||||
text: resultText,
|
||||
cursors: merged.cursors
|
||||
};
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
let expectedText = "";
|
||||
let newText = "";
|
||||
try {
|
||||
expectedText = new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
expectedContent
|
||||
); // this comes from a previous read which must only have \n line endings
|
||||
newText = new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
newContent
|
||||
); // this comes from the server which stores text with \n line endings
|
||||
} catch (decodeError) {
|
||||
this.logger.warn(
|
||||
`3-way merge aborted for ${path}: one of expected/new is not valid UTF-8 (${decodeError}); falling back to overwrite`
|
||||
);
|
||||
await this.fs.write(path, this.toNativeLineEndings(newContent));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fs.atomicUpdateText(
|
||||
path,
|
||||
({ text, cursors }: TextWithCursors): TextWithCursors => {
|
||||
this.logger.debug(
|
||||
`Performing a 3-way merge for ${path} with the expected content`
|
||||
);
|
||||
|
||||
text = text.replaceAll(this.nativeLineEndings, "\n");
|
||||
const merged = reconcile(
|
||||
expectedText,
|
||||
{ text, cursors },
|
||||
newText
|
||||
);
|
||||
|
||||
const resultText = merged.text.replaceAll(
|
||||
"\n",
|
||||
this.nativeLineEndings
|
||||
);
|
||||
|
||||
return {
|
||||
text: resultText,
|
||||
cursors: merged.cursors
|
||||
};
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
this.expectedFsEvents.unexpectUpdate(path);
|
||||
if (e instanceof FileNotFoundError) {
|
||||
this.logger.debug(
|
||||
`File ${path} disappeared during write; not recreating`
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(path: RelativePath): Promise<void> {
|
||||
if (await this.exists(path)) {
|
||||
await this.fs.delete(path);
|
||||
this.expectedFsEvents.expectDelete(path);
|
||||
try {
|
||||
await this.fs.delete(path);
|
||||
} catch (e) {
|
||||
this.expectedFsEvents.unexpectDelete(path);
|
||||
throw e;
|
||||
}
|
||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(path);
|
||||
} else {
|
||||
this.logger.debug(`No need to delete '${path}', it doesn't exist`);
|
||||
|
|
@ -160,23 +209,39 @@ export class FileOperations {
|
|||
return this.fs.exists(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the file at `oldPath` to `newPath`.
|
||||
*
|
||||
* Throws `FileAlreadyExistsError` if a file already lives at `newPath`
|
||||
* (and `oldPath !== newPath`). The reconciler is the only caller that
|
||||
* relocates tracked records and pre-checks for conflicts; the throw
|
||||
* guards against a TOCTOU race.
|
||||
*/
|
||||
public async move(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
): Promise<FileOpResult> {
|
||||
if (oldPath === newPath) {
|
||||
return;
|
||||
return { actualPath: oldPath };
|
||||
}
|
||||
|
||||
await this.ensureClearPath(newPath);
|
||||
if (await this.fs.exists(newPath)) {
|
||||
throw new FileAlreadyExistsError(
|
||||
`Refusing to move '${oldPath}' onto '${newPath}': target already exists`,
|
||||
newPath
|
||||
);
|
||||
}
|
||||
await this.createParentDirectories(newPath);
|
||||
|
||||
this.database.move(oldPath, newPath);
|
||||
await this.fs.rename(oldPath, newPath);
|
||||
this.expectedFsEvents.expectRename(oldPath, newPath);
|
||||
try {
|
||||
await this.fs.rename(oldPath, newPath);
|
||||
} catch (e) {
|
||||
this.expectedFsEvents.unexpectRename(oldPath, newPath);
|
||||
throw e;
|
||||
}
|
||||
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.fs.reset();
|
||||
return { actualPath: newPath };
|
||||
}
|
||||
|
||||
private async deletingEmptyParentDirectoriesOfDeletedFile(
|
||||
|
|
@ -185,7 +250,7 @@ export class FileOperations {
|
|||
let directory = path;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
[directory] = FileOperations.getParentDirAndFile(directory);
|
||||
[directory] = FileOperations.getParentDirAndFileName(directory);
|
||||
if (directory.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
|
@ -237,55 +302,4 @@ export class FileOperations {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found.
|
||||
* The returned path has a lock acquired on it; it must be released by the caller when no longer needed.
|
||||
*
|
||||
* @param path The starting path to deconflict
|
||||
* @returns a non-existent path with a lock acquired on it
|
||||
*/
|
||||
private async deconflictPath(path: RelativePath): Promise<RelativePath> {
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [directory, fileName] = FileOperations.getParentDirAndFile(path);
|
||||
|
||||
if (directory) {
|
||||
directory += "/";
|
||||
}
|
||||
|
||||
const nameParts = fileName.split(".");
|
||||
// Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json"
|
||||
const isDotfile = fileName.startsWith(".") && nameParts[0] === "";
|
||||
const extension =
|
||||
nameParts.length > 1 && !(isDotfile && nameParts.length === 2)
|
||||
? "." + nameParts[nameParts.length - 1]
|
||||
: "";
|
||||
let stem = extension ? nameParts.slice(0, -1).join(".") : fileName;
|
||||
let currentCount = Number.parseInt(
|
||||
FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0"
|
||||
);
|
||||
stem = stem.replace(FileOperations.PARENTHESES_REGEX, "");
|
||||
|
||||
let newName = path;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
currentCount++;
|
||||
newName = `${directory}${stem} (${currentCount})${extension}`;
|
||||
|
||||
// Avoid multiple deconflictPath calls returning the same path
|
||||
if (this.fs.tryLock(newName)) {
|
||||
const newDocument =
|
||||
this.database.getLatestDocumentByRelativePath(newName);
|
||||
if (
|
||||
newDocument?.isDeleted === false || // the document might have been confirmed by the server at a new path but haven't yet moved there locally
|
||||
(await this.fs.exists(newName, true))
|
||||
) {
|
||||
this.fs.unlock(newName);
|
||||
} else {
|
||||
return newName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { RelativePath } from "../persistence/database";
|
||||
import type { RelativePath } from "../sync-operations/types";
|
||||
|
||||
import type { TextWithCursors } from "reconcile-text";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,18 @@
|
|||
import type { RelativePath } from "../persistence/database";
|
||||
import type { RelativePath } from "../sync-operations/types";
|
||||
import type { FileSystemOperations } from "./filesystem-operations";
|
||||
import type { Logger } from "../tracing/logger";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Decorates `FileSystemOperations` to replace errors with `FileNotFoundError`
|
||||
* if the accessed file doesn't exist. It also ensures that there's at most a
|
||||
* single request in-flight for any one file through the use of locks.
|
||||
* if the accessed file doesn't exist.
|
||||
*/
|
||||
export class SafeFileSystemOperations implements FileSystemOperations {
|
||||
private readonly locks: Locks<RelativePath>;
|
||||
|
||||
public constructor(
|
||||
private readonly fs: FileSystemOperations,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.locks = new Locks(logger);
|
||||
}
|
||||
) {}
|
||||
|
||||
public async listFilesRecursively(
|
||||
root: RelativePath | undefined
|
||||
|
|
@ -31,19 +25,12 @@ export class SafeFileSystemOperations implements FileSystemOperations {
|
|||
|
||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||
this.logger.debug(`Reading file '${path}'`);
|
||||
return this.safeOperation(
|
||||
path,
|
||||
async () =>
|
||||
this.locks.withLock(path, async () => this.fs.read(path)),
|
||||
"read"
|
||||
);
|
||||
return this.safeOperation(path, async () => this.fs.read(path), "read");
|
||||
}
|
||||
|
||||
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||
this.logger.debug(`Writing to file '${path}'`);
|
||||
return this.locks.withLock(path, async () =>
|
||||
this.fs.write(path, content)
|
||||
);
|
||||
return this.fs.write(path, content);
|
||||
}
|
||||
|
||||
public async atomicUpdateText(
|
||||
|
|
@ -53,10 +40,7 @@ export class SafeFileSystemOperations implements FileSystemOperations {
|
|||
this.logger.debug(`Atomically updating file '${path}'`);
|
||||
return this.safeOperation(
|
||||
path,
|
||||
async () =>
|
||||
this.locks.withLock(path, async () =>
|
||||
this.fs.atomicUpdateText(path, updater)
|
||||
),
|
||||
async () => this.fs.atomicUpdateText(path, updater),
|
||||
"atomicUpdateText"
|
||||
);
|
||||
}
|
||||
|
|
@ -65,80 +49,43 @@ export class SafeFileSystemOperations implements FileSystemOperations {
|
|||
// Logging this would be too noisy
|
||||
return this.safeOperation(
|
||||
path,
|
||||
async () =>
|
||||
this.locks.withLock(path, async () =>
|
||||
this.fs.getFileSize(path)
|
||||
),
|
||||
async () => this.fs.getFileSize(path),
|
||||
"getFileSize"
|
||||
);
|
||||
}
|
||||
|
||||
public async exists(
|
||||
path: RelativePath,
|
||||
skipLock = false
|
||||
): Promise<boolean> {
|
||||
public async exists(path: RelativePath): Promise<boolean> {
|
||||
this.logger.debug(`Checking if file '${path}' exists`);
|
||||
if (skipLock) {
|
||||
return this.fs.exists(path);
|
||||
} else {
|
||||
return this.locks.withLock(path, async () => this.fs.exists(path));
|
||||
}
|
||||
return this.fs.exists(path);
|
||||
}
|
||||
|
||||
public async createDirectory(path: RelativePath): Promise<void> {
|
||||
this.logger.debug(`Creating directory '${path}'`);
|
||||
return this.locks.withLock(path, async () =>
|
||||
this.fs.createDirectory(path)
|
||||
);
|
||||
return this.fs.createDirectory(path);
|
||||
}
|
||||
|
||||
public async delete(path: RelativePath): Promise<void> {
|
||||
this.logger.debug(`Deleting file '${path}'`);
|
||||
return this.locks.withLock(path, async () => this.fs.delete(path));
|
||||
return this.fs.delete(path);
|
||||
}
|
||||
|
||||
public async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath,
|
||||
skipLock = false
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
|
||||
return this.safeOperation(
|
||||
oldPath,
|
||||
async () => {
|
||||
if (skipLock) {
|
||||
return this.fs.rename(oldPath, newPath);
|
||||
} else {
|
||||
return this.locks.withLock([oldPath, newPath], async () =>
|
||||
this.fs.rename(oldPath, newPath)
|
||||
);
|
||||
}
|
||||
},
|
||||
async () => this.fs.rename(oldPath, newPath),
|
||||
"rename"
|
||||
);
|
||||
}
|
||||
|
||||
public tryLock(path: RelativePath): boolean {
|
||||
return this.locks.tryLock(path);
|
||||
}
|
||||
|
||||
public async waitForLock(path: RelativePath): Promise<void> {
|
||||
return this.locks.waitForLock(path);
|
||||
}
|
||||
|
||||
public unlock(path: RelativePath): void {
|
||||
this.locks.unlock(path);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.locks.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorate an operation to ensure that the file exists before running it.
|
||||
* If the operation fails, it will check if the file still exists and throw
|
||||
* a FileNotFoundError if it doesn't.
|
||||
*/
|
||||
* Decorate an operation to ensure that the file exists before running it.
|
||||
* If the operation fails, it will check if the file still exists and throw
|
||||
* a FileNotFoundError if it doesn't.
|
||||
*/
|
||||
private async safeOperation<T>(
|
||||
path: RelativePath,
|
||||
operation: () => Promise<T>,
|
||||
|
|
@ -154,9 +101,6 @@ export class SafeFileSystemOperations implements FileSystemOperations {
|
|||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
// Without locking the file, this isn't atomic, however, it's good enough in practice.
|
||||
// This will only break if the file exists, gets deleted and then immediately
|
||||
// recreated while `operation` is running.
|
||||
if (await this.fs.exists(path)) {
|
||||
throw error;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { awaitAll } from "./utils/await-all";
|
|||
import { logToConsole } from "./utils/debugging/log-to-console";
|
||||
import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory";
|
||||
import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory";
|
||||
import { InMemoryFileSystem } from "./utils/debugging/in-memory-file-system";
|
||||
import { getRandomColor } from "./utils/get-random-color";
|
||||
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
|
||||
import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
|
||||
|
|
@ -21,14 +22,19 @@ export {
|
|||
export { Logger, LogLevel, LogLine } from "./tracing/logger";
|
||||
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
|
||||
export { rateLimit } from "./utils/rate-limit";
|
||||
export type { RelativePath, StoredDatabase } from "./persistence/database";
|
||||
export type {
|
||||
RelativePath,
|
||||
StoredSyncState as StoredDatabase,
|
||||
DocumentRecord
|
||||
} from "./sync-operations/types";
|
||||
export type { FileSystemOperations } from "./file-operations/filesystem-operations";
|
||||
export type { PersistenceProvider } from "./persistence/persistence";
|
||||
export type { CursorSpan } from "./services/types/CursorSpan";
|
||||
export type { ClientCursors } from "./services/types/ClientCursors";
|
||||
export type { NetworkConnectionStatus } from "./types/network-connection-status";
|
||||
export type { ServerVersionMismatchError } from "./services/server-version-mismatch-error";
|
||||
export type { AuthenticationError } from "./services/authentication-error";
|
||||
export type { ServerVersionMismatchError } from "./errors/server-version-mismatch-error";
|
||||
export type { AuthenticationError } from "./errors/authentication-error";
|
||||
export { SyncResetError } from "./errors/sync-reset-error";
|
||||
export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
|
||||
export { DocumentSyncStatus } from "./types/document-sync-status";
|
||||
export { SyncClient } from "./sync-client";
|
||||
|
|
@ -37,7 +43,8 @@ export type { TextWithCursors, CursorPosition } from "reconcile-text";
|
|||
export const debugging = {
|
||||
slowFetchFactory,
|
||||
slowWebSocketFactory,
|
||||
logToConsole
|
||||
logToConsole,
|
||||
InMemoryFileSystem
|
||||
};
|
||||
|
||||
export const utils = {
|
||||
|
|
|
|||
|
|
@ -1,374 +0,0 @@
|
|||
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 DocumentId = string;
|
||||
export type RelativePath = string;
|
||||
|
||||
export interface DocumentMetadata {
|
||||
parentVersionId: VaultUpdateId;
|
||||
hash: string;
|
||||
remoteRelativePath?: RelativePath;
|
||||
}
|
||||
|
||||
export interface StoredDocumentMetadata {
|
||||
relativePath: RelativePath;
|
||||
documentId: DocumentId;
|
||||
parentVersionId: VaultUpdateId;
|
||||
remoteRelativePath?: RelativePath;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface StoredDatabase {
|
||||
documents: StoredDocumentMetadata[];
|
||||
lastSeenUpdateId: VaultUpdateId | undefined;
|
||||
hasInitialSyncCompleted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a document in the database.
|
||||
*
|
||||
* It is mutable and its content should always represent the latest
|
||||
* state of the document on disk based on the update events we have seen.
|
||||
*/
|
||||
export interface DocumentRecord {
|
||||
relativePath: RelativePath;
|
||||
documentId: DocumentId;
|
||||
metadata: DocumentMetadata | undefined;
|
||||
isDeleted: boolean;
|
||||
updates: Promise<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;
|
||||
token: string;
|
||||
vaultName: string;
|
||||
syncConcurrency: number;
|
||||
isSyncEnabled: boolean;
|
||||
maxFileSizeMB: number;
|
||||
ignorePatterns: string[];
|
||||
|
|
@ -14,22 +13,19 @@ export interface SyncSettings {
|
|||
diffCacheSizeMB: number;
|
||||
enableTelemetry: boolean;
|
||||
networkRetryIntervalMs: number;
|
||||
minimumSaveIntervalMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: SyncSettings = {
|
||||
remoteUri: "",
|
||||
token: "",
|
||||
vaultName: "default",
|
||||
syncConcurrency: 1,
|
||||
isSyncEnabled: false,
|
||||
maxFileSizeMB: 10,
|
||||
ignorePatterns: [],
|
||||
webSocketRetryIntervalMs: 3500,
|
||||
diffCacheSizeMB: 4,
|
||||
enableTelemetry: false,
|
||||
networkRetryIntervalMs: 1000,
|
||||
minimumSaveIntervalMs: 1000
|
||||
networkRetryIntervalMs: 1000
|
||||
};
|
||||
|
||||
export class Settings {
|
||||
|
|
@ -38,7 +34,7 @@ export class Settings {
|
|||
>();
|
||||
|
||||
private settings: SyncSettings;
|
||||
private readonly lock: Lock = new Lock();
|
||||
private readonly lock: Lock;
|
||||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
|
|
@ -50,6 +46,8 @@ export class Settings {
|
|||
...(initialState ?? {})
|
||||
};
|
||||
|
||||
this.lock = new Lock(Settings.name, this.logger);
|
||||
|
||||
this.logger.debug(
|
||||
`Loaded settings: ${JSON.stringify(this.settings, null, 2)}`
|
||||
);
|
||||
|
|
|
|||
8
frontend/sync-client/src/services/build-vault-url.ts
Normal file
8
frontend/sync-client/src/services/build-vault-url.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { Settings } from "../persistence/settings";
|
||||
|
||||
export function buildVaultUrl(settings: Settings, path: string): string {
|
||||
const { vaultName, remoteUri } = settings.getSettings();
|
||||
const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, "");
|
||||
const encodedVaultName = encodeURIComponent(vaultName.trim());
|
||||
return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`;
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { describe, it, mock, beforeEach, afterEach } from "node:test";
|
|||
import assert from "node:assert";
|
||||
import { FetchController } from "./fetch-controller";
|
||||
import { Logger } from "../tracing/logger";
|
||||
import { SyncResetError } from "./sync-reset-error";
|
||||
import { SyncResetError } from "../errors/sync-reset-error";
|
||||
import { sleep } from "../utils/sleep";
|
||||
|
||||
describe("FetchController", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import type { Logger } from "../tracing/logger";
|
||||
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
|
||||
|
|
@ -13,37 +12,43 @@ export class FetchController {
|
|||
|
||||
// Promise resolves on the next state change: sync enabled/disabled or reset started/ended
|
||||
private until: Promise<symbol>;
|
||||
private resolveUntil: (result: symbol) => unknown;
|
||||
private rejectUntil: (reason: unknown) => unknown;
|
||||
private resolveUntil: (value: symbol | PromiseLike<symbol>) => void;
|
||||
private rejectUntil: (reason?: unknown) => void;
|
||||
|
||||
public constructor(
|
||||
private _canFetch: boolean,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
[this.until, this.resolveUntil, this.rejectUntil] =
|
||||
createPromise<symbol>();
|
||||
({
|
||||
promise: this.until,
|
||||
resolve: this.resolveUntil,
|
||||
reject: this.rejectUntil
|
||||
} = Promise.withResolvers<symbol>());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return this._canFetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow or disallow fetching. The changes only take effect if not resetting.
|
||||
* When called during a reset, its effect is deferred until the reset is finished.
|
||||
*
|
||||
* @param canFetch Whether fetching is enabled
|
||||
*/
|
||||
* Allow or disallow fetching. The changes only take effect if not resetting.
|
||||
* When called during a reset, its effect is deferred until the reset is finished.
|
||||
*
|
||||
* @param canFetch Whether fetching is enabled
|
||||
*/
|
||||
public set canFetch(canFetch: boolean) {
|
||||
this._canFetch = canFetch;
|
||||
|
||||
if (!this.isResetting) {
|
||||
const previousResolve = this.resolveUntil;
|
||||
[this.until, this.resolveUntil, this.rejectUntil] =
|
||||
createPromise<symbol>();
|
||||
({
|
||||
promise: this.until,
|
||||
resolve: this.resolveUntil,
|
||||
reject: this.rejectUntil
|
||||
} = Promise.withResolvers<symbol>());
|
||||
previousResolve(FetchController.UNTIL_RESOLUTION);
|
||||
}
|
||||
}
|
||||
|
|
@ -59,9 +64,9 @@ export class FetchController {
|
|||
}
|
||||
|
||||
/**
|
||||
* Starts a reset, causing all ongoing and future fetches to be rejected
|
||||
* with a SyncResetError until finishReset is called.
|
||||
*/
|
||||
* Starts a reset, causing all ongoing and future fetches to be rejected
|
||||
* with a SyncResetError until finishReset is called.
|
||||
*/
|
||||
public startReset(): void {
|
||||
this.isResetting = true;
|
||||
this.rejectUntil(new SyncResetError());
|
||||
|
|
@ -72,32 +77,36 @@ export class FetchController {
|
|||
}
|
||||
|
||||
/**
|
||||
* Finishes a reset, allowing fetches to proceed or wait again depending on
|
||||
* the current sync settings.
|
||||
*/
|
||||
* Finishes a reset, allowing fetches to proceed or wait again depending on
|
||||
* the current sync settings.
|
||||
*/
|
||||
public finishReset(): void {
|
||||
if (!this.isResetting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isResetting = false;
|
||||
[this.until, this.resolveUntil, this.rejectUntil] = createPromise();
|
||||
({
|
||||
promise: this.until,
|
||||
resolve: this.resolveUntil,
|
||||
reject: this.rejectUntil
|
||||
} = Promise.withResolvers<symbol>());
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* |------------------|---------------|-----------------------------------------------------|
|
||||
* | | Sync enabled | Sync disabled |
|
||||
* |------------------|-------------- |-----------------------------------------------------|
|
||||
* | During reset | Rejects with SyncResetError without sending request |
|
||||
* |------------------|-------------- |-----------------------------------------------------|
|
||||
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
|
||||
* |------------------|---------------|-----------------------------------------------------|
|
||||
*
|
||||
* @param logger for errors
|
||||
* @param fetch to wrap
|
||||
* @returns a wrapped fetch implementation affected by the FetchController state
|
||||
*/
|
||||
*
|
||||
* |------------------|---------------|-----------------------------------------------------|
|
||||
* | | Sync enabled | Sync disabled |
|
||||
* |------------------|-------------- |-----------------------------------------------------|
|
||||
* | During reset | Rejects with SyncResetError without sending request |
|
||||
* |------------------|-------------- |-----------------------------------------------------|
|
||||
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
|
||||
* |------------------|---------------|-----------------------------------------------------|
|
||||
*
|
||||
* @param logger for errors
|
||||
* @param fetch to wrap
|
||||
* @returns a wrapped fetch implementation affected by the FetchController state
|
||||
*/
|
||||
public getControlledFetchImplementation(
|
||||
logger: Logger,
|
||||
fetch: typeof globalThis.fetch = globalThis.fetch
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { SUPPORTED_API_VERSION } from "../consts";
|
||||
import { AuthenticationError } from "./authentication-error";
|
||||
import { ServerVersionMismatchError } from "./server-version-mismatch-error";
|
||||
import { AuthenticationError } from "../errors/authentication-error";
|
||||
import { ServerVersionMismatchError } from "../errors/server-version-mismatch-error";
|
||||
import type { Settings } from "../persistence/settings";
|
||||
import type { SyncService } from "./sync-service";
|
||||
import type { PingResponse } from "./types/PingResponse";
|
||||
|
||||
|
|
@ -14,7 +15,20 @@ export class ServerConfig {
|
|||
private response: Promise<PingResponse> | undefined;
|
||||
private config: ServerConfigData | undefined;
|
||||
|
||||
public constructor(private readonly syncService: SyncService) {}
|
||||
public constructor(
|
||||
private readonly syncService: SyncService,
|
||||
settings: Settings
|
||||
) {
|
||||
settings.onSettingsChanged.add((newSettings, oldSettings) => {
|
||||
if (
|
||||
newSettings.token !== oldSettings.token ||
|
||||
newSettings.vaultName !== oldSettings.vaultName ||
|
||||
newSettings.remoteUri !== oldSettings.remoteUri
|
||||
) {
|
||||
this.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static validateConfig(config: ServerConfigData): void {
|
||||
if (config.supportedApiVersion !== SUPPORTED_API_VERSION) {
|
||||
|
|
@ -34,11 +48,6 @@ export class ServerConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// warm the cache
|
||||
public async initialize(): Promise<void> {
|
||||
await this.getConfig();
|
||||
}
|
||||
|
||||
public async checkConnection(forceUpdate = false): Promise<{
|
||||
isSuccessful: boolean;
|
||||
message: string;
|
||||
|
|
@ -46,7 +55,7 @@ export class ServerConfig {
|
|||
try {
|
||||
let { response } = this;
|
||||
if (!response || forceUpdate) {
|
||||
response = this.response = this.syncService.ping();
|
||||
response = this.startPing();
|
||||
}
|
||||
|
||||
const result: PingResponse = await response; // it must be defined, otherwise we would have thrown above
|
||||
|
|
@ -73,7 +82,7 @@ export class ServerConfig {
|
|||
|
||||
public async getConfig(): Promise<ServerConfigData> {
|
||||
if (!this.config) {
|
||||
this.response ??= this.syncService.ping();
|
||||
this.response ??= this.startPing();
|
||||
this.config = await this.response;
|
||||
}
|
||||
|
||||
|
|
@ -86,4 +95,15 @@ export class ServerConfig {
|
|||
this.response = undefined;
|
||||
this.config = undefined;
|
||||
}
|
||||
|
||||
private async startPing(): Promise<PingResponse> {
|
||||
const pending = this.syncService.ping().catch((e: unknown) => {
|
||||
if (this.response === pending) {
|
||||
this.response = undefined;
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
this.response = pending;
|
||||
return pending;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,25 +2,27 @@ import type {
|
|||
DocumentId,
|
||||
RelativePath,
|
||||
VaultUpdateId
|
||||
} from "../persistence/database";
|
||||
} from "../sync-operations/types";
|
||||
|
||||
import type { Logger } from "../tracing/logger";
|
||||
import type { Settings } from "../persistence/settings";
|
||||
import type { FetchController } from "./fetch-controller";
|
||||
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 { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
||||
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
|
||||
import type { DocumentVersion } from "./types/DocumentVersion";
|
||||
import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse";
|
||||
import type { PingResponse } from "./types/PingResponse";
|
||||
import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion";
|
||||
import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion";
|
||||
import { buildVaultUrl } from "./build-vault-url";
|
||||
|
||||
export class SyncService {
|
||||
private readonly client: typeof globalThis.fetch;
|
||||
private readonly pingClient: typeof globalThis.fetch;
|
||||
private isStopped = false;
|
||||
|
||||
public constructor(
|
||||
private readonly deviceId: string,
|
||||
|
|
@ -65,28 +67,68 @@ export class SyncService {
|
|||
return result;
|
||||
}
|
||||
|
||||
private static async throwIfNotOk(
|
||||
response: Response,
|
||||
operation: string
|
||||
): Promise<void> {
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
const message = `Failed to ${operation}: ${await SyncService.errorFromResponse(response)}`;
|
||||
// 429 is the only 4xx the server uses for *transient* contention
|
||||
// (`WriteBusyError` → HTTP 429). Every other 4xx means the request
|
||||
// is permanently rejected and shouldn't be retried.
|
||||
if (response.status === 429) {
|
||||
throw new Error(message);
|
||||
}
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
throw new HttpClientError(response.status, message);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal that the service is shutting down so any in-flight
|
||||
* `retryForever` exits at its next iteration instead of looping
|
||||
* indefinitely after the rest of the client has stopped. Idempotent.
|
||||
*/
|
||||
public stop(): void {
|
||||
this.isStopped = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enable the service after a `stop()`. Used when the client pauses
|
||||
* and resumes syncing within the same lifecycle (e.g. user toggles
|
||||
* sync off and on).
|
||||
*/
|
||||
public resume(): void {
|
||||
this.isStopped = false;
|
||||
}
|
||||
|
||||
public async create({
|
||||
documentId,
|
||||
relativePath,
|
||||
lastSeenVaultUpdateId,
|
||||
contentBytes
|
||||
}: {
|
||||
documentId?: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
lastSeenVaultUpdateId: VaultUpdateId;
|
||||
contentBytes: Uint8Array;
|
||||
}): Promise<DocumentVersionWithoutContent> {
|
||||
}): Promise<DocumentUpdateResponse> {
|
||||
return this.retryForever(async () => {
|
||||
const formData = new FormData();
|
||||
if (documentId !== undefined) {
|
||||
formData.append("document_id", documentId);
|
||||
}
|
||||
|
||||
formData.append("relative_path", relativePath);
|
||||
formData.append(
|
||||
"last_seen_vault_update_id",
|
||||
lastSeenVaultUpdateId.toString()
|
||||
);
|
||||
formData.append(
|
||||
"content",
|
||||
new Blob([new Uint8Array(contentBytes)])
|
||||
);
|
||||
|
||||
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"), {
|
||||
|
|
@ -95,16 +137,10 @@ export class SyncService {
|
|||
headers: this.getDefaultHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to create document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
await SyncService.throwIfNotOk(response, "create document");
|
||||
|
||||
const result: DocumentVersionWithoutContent =
|
||||
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const result: DocumentUpdateResponse =
|
||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
this.logger.debug(`Created document ${JSON.stringify(result)}`);
|
||||
|
||||
|
|
@ -120,17 +156,17 @@ export class SyncService {
|
|||
}: {
|
||||
parentVersionId: VaultUpdateId;
|
||||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
relativePath: RelativePath | undefined;
|
||||
content: (number | string)[];
|
||||
}): Promise<DocumentUpdateResponse> {
|
||||
return this.retryForever(async () => {
|
||||
this.logger.debug(
|
||||
`Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]`
|
||||
`Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath ?? "<unchanged>"}, content [${content.join(", ")}]`
|
||||
);
|
||||
|
||||
const request: UpdateTextDocumentVersion = {
|
||||
parentVersionId,
|
||||
relativePath,
|
||||
relativePath: relativePath ?? null,
|
||||
content
|
||||
};
|
||||
|
||||
|
|
@ -143,13 +179,7 @@ export class SyncService {
|
|||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to update document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
await SyncService.throwIfNotOk(response, "update document");
|
||||
|
||||
const result: DocumentUpdateResponse =
|
||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
|
@ -172,16 +202,18 @@ export class SyncService {
|
|||
}: {
|
||||
parentVersionId: VaultUpdateId;
|
||||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
relativePath: RelativePath | undefined;
|
||||
contentBytes: Uint8Array;
|
||||
}): Promise<DocumentUpdateResponse> {
|
||||
return this.retryForever(async () => {
|
||||
this.logger.debug(
|
||||
`Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}`
|
||||
`Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath ?? "<unchanged>"}`
|
||||
);
|
||||
const formData = new FormData();
|
||||
formData.append("parent_version_id", parentVersionId.toString());
|
||||
formData.append("relative_path", relativePath);
|
||||
if (relativePath !== undefined) {
|
||||
formData.append("relative_path", relativePath);
|
||||
}
|
||||
formData.append(
|
||||
"content",
|
||||
new Blob([new Uint8Array(contentBytes)])
|
||||
|
|
@ -196,13 +228,7 @@ export class SyncService {
|
|||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to update document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
await SyncService.throwIfNotOk(response, "update document");
|
||||
|
||||
const result: DocumentUpdateResponse =
|
||||
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
|
@ -218,44 +244,29 @@ export class SyncService {
|
|||
}
|
||||
|
||||
public async delete({
|
||||
documentId,
|
||||
relativePath
|
||||
documentId
|
||||
}: {
|
||||
documentId: DocumentId;
|
||||
relativePath: RelativePath;
|
||||
}): Promise<DocumentVersionWithoutContent> {
|
||||
return this.retryForever(async () => {
|
||||
const request: DeleteDocumentVersion = {
|
||||
relativePath
|
||||
};
|
||||
|
||||
this.logger.debug(
|
||||
`Delete document with id ${documentId} and relative path ${relativePath}`
|
||||
);
|
||||
this.logger.debug(`Delete document with id ${documentId}`);
|
||||
|
||||
// The server identifies the document by its URL path; no body
|
||||
// is needed. Sending one was a leftover of an earlier shape.
|
||||
const response = await this.client(
|
||||
this.getUrl(`/documents/${documentId}`),
|
||||
{
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(request),
|
||||
headers: this.getDefaultHeaders({ type: "json" })
|
||||
headers: this.getDefaultHeaders()
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to delete document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
await SyncService.throwIfNotOk(response, "delete document");
|
||||
|
||||
const result: DocumentVersionWithoutContent =
|
||||
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
||||
this.logger.debug(
|
||||
`Deleted document ${relativePath} with id ${documentId}`
|
||||
);
|
||||
this.logger.debug(`Deleted document with id ${documentId}`);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
|
@ -276,13 +287,7 @@ export class SyncService {
|
|||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
await SyncService.throwIfNotOk(response, "get document");
|
||||
|
||||
const result: DocumentVersion =
|
||||
(await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
|
@ -314,13 +319,10 @@ export class SyncService {
|
|||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get document: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
await SyncService.throwIfNotOk(
|
||||
response,
|
||||
"get document version content"
|
||||
);
|
||||
|
||||
const result = await response.bytes();
|
||||
this.logger.debug(
|
||||
|
|
@ -341,19 +343,13 @@ export class SyncService {
|
|||
|
||||
const url = new URL(this.getUrl("/documents"));
|
||||
if (since !== undefined) {
|
||||
url.searchParams.append("since", since.toString());
|
||||
url.searchParams.append("since_update_id", since.toString());
|
||||
}
|
||||
const response = await this.client(url.toString(), {
|
||||
headers: this.getDefaultHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to get documents: ${await SyncService.errorFromResponse(
|
||||
response
|
||||
)}`
|
||||
);
|
||||
}
|
||||
await SyncService.throwIfNotOk(response, "get documents");
|
||||
|
||||
const result: FetchLatestDocumentsResponse =
|
||||
(await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
|
|
@ -390,10 +386,7 @@ export class SyncService {
|
|||
}
|
||||
|
||||
private getUrl(path: string): string {
|
||||
const { vaultName, remoteUri } = this.settings.getSettings();
|
||||
const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, "");
|
||||
const encodedVaultName = encodeURIComponent(vaultName.trim());
|
||||
return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`;
|
||||
return buildVaultUrl(this.settings, path);
|
||||
}
|
||||
|
||||
private getDefaultHeaders(
|
||||
|
|
@ -414,13 +407,17 @@ export class SyncService {
|
|||
private async retryForever<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
this.throwIfStopped();
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
// We must not retry errors coming from reset
|
||||
if (e instanceof SyncResetError) {
|
||||
if (
|
||||
e instanceof SyncResetError ||
|
||||
e instanceof HttpClientError
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
this.throwIfStopped();
|
||||
|
||||
const retryInterval =
|
||||
this.settings.getSettings().networkRetryIntervalMs;
|
||||
|
|
@ -431,4 +428,10 @@ export class SyncService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private throwIfStopped(): void {
|
||||
if (this.isStopped) {
|
||||
throw new SyncResetError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,7 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export interface CreateDocumentVersion {
|
||||
/**
|
||||
* The client can decide the document id (if it wishes to) in order
|
||||
* to help with syncing. If the client does not provide a document id,
|
||||
* the server will generate one. If the client provides a document id
|
||||
* it must not already exist in the database.
|
||||
*/
|
||||
document_id: string | null;
|
||||
relative_path: string;
|
||||
last_seen_vault_update_id: number;
|
||||
content: number[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { DocumentVersion } from "./DocumentVersion";
|
|||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||
|
||||
/**
|
||||
* Response to an update document request.
|
||||
* Response to a create/update document request.
|
||||
*/
|
||||
export type DocumentUpdateResponse =
|
||||
| ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent)
|
||||
|
|
|
|||
|
|
@ -9,4 +9,8 @@ export interface DocumentVersionWithoutContent {
|
|||
userId: string;
|
||||
deviceId: string;
|
||||
contentSize: number;
|
||||
/**
|
||||
* True iff this is the first version of the document
|
||||
*/
|
||||
isNewFile: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
import type { CursorSpan } from "./CursorSpan";
|
||||
|
||||
export interface DocumentWithCursors {
|
||||
vault_update_id: number | null;
|
||||
document_id: string;
|
||||
relative_path: string;
|
||||
vaultUpdateId: number | null;
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
cursors: CursorSpan[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont
|
|||
export interface FetchLatestDocumentsResponse {
|
||||
latestDocuments: DocumentVersionWithoutContent[];
|
||||
/**
|
||||
* The update ID of the latest document in the response.
|
||||
*/
|
||||
* The update ID of the latest document in the response.
|
||||
*/
|
||||
lastUpdateId: bigint;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { VaultInfo } from "./VaultInfo";
|
||||
|
||||
/**
|
||||
* Response to listing vaults accessible to the authenticated user.
|
||||
*/
|
||||
export interface ListVaultsResponse {
|
||||
vaults: VaultInfo[];
|
||||
hasMore: boolean;
|
||||
userName: string;
|
||||
}
|
||||
|
|
@ -5,21 +5,21 @@
|
|||
*/
|
||||
export interface PingResponse {
|
||||
/**
|
||||
* Semantic version of the server.
|
||||
*/
|
||||
* Semantic version of the server.
|
||||
*/
|
||||
serverVersion: string;
|
||||
/**
|
||||
* Whether the client is authenticated based on the sent Authorization
|
||||
* header.
|
||||
*/
|
||||
* Whether the client is authenticated based on the sent Authorization
|
||||
* header.
|
||||
*/
|
||||
isAuthenticated: boolean;
|
||||
/**
|
||||
* List of file extensions that are allowed to be merged.
|
||||
*/
|
||||
* List of file extensions that are allowed to be merged.
|
||||
*/
|
||||
mergeableFileExtensions: string[];
|
||||
/**
|
||||
* API version ensuring backwards & forwards compatibility between the client
|
||||
* and server.
|
||||
*/
|
||||
* API version ensuring backwards & forwards compatibility between the client
|
||||
* and server.
|
||||
*/
|
||||
supportedApiVersion: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@
|
|||
|
||||
export interface UpdateTextDocumentVersion {
|
||||
parentVersionId: number;
|
||||
relativePath: string;
|
||||
relativePath: string | null;
|
||||
content: (number | string)[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||
|
||||
/**
|
||||
* Response to a vault history request (paginated).
|
||||
*/
|
||||
export interface VaultHistoryResponse {
|
||||
versions: DocumentVersionWithoutContent[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
10
frontend/sync-client/src/services/types/VaultInfo.ts
Normal file
10
frontend/sync-client/src/services/types/VaultInfo.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Summary of a single vault returned by the list-vaults endpoint.
|
||||
*/
|
||||
export interface VaultInfo {
|
||||
name: string;
|
||||
documentCount: number;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
|
@ -2,6 +2,5 @@
|
|||
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
|
||||
|
||||
export interface WebSocketVaultUpdate {
|
||||
documents: DocumentVersionWithoutContent[];
|
||||
isInitialSync: boolean;
|
||||
document: DocumentVersionWithoutContent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import assert from "node:assert";
|
|||
import { WebSocketManager } from "./websocket-manager";
|
||||
import type { Logger } from "../tracing/logger";
|
||||
import type { Settings } from "../persistence/settings";
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const WebSocket = require("ws") as typeof globalThis.WebSocket;
|
||||
import { awaitAll } from "../utils/await-all";
|
||||
|
||||
class MockCloseEvent extends Event {
|
||||
public code: number;
|
||||
|
|
@ -91,10 +90,8 @@ function createMockFn<T extends (...args: unknown[]) => unknown>(
|
|||
describe("WebSocketManager", () => {
|
||||
let mockLogger: Logger = undefined as unknown as Logger;
|
||||
let mockSettings: Settings = undefined as unknown as Settings;
|
||||
let deviceId = "test-device-123";
|
||||
|
||||
beforeEach(() => {
|
||||
deviceId = "test-device-123";
|
||||
const noop = (): void => {
|
||||
// Intentionally empty for mock
|
||||
};
|
||||
|
|
@ -116,7 +113,6 @@ describe("WebSocketManager", () => {
|
|||
|
||||
it("cleans up promises after message handling", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
|
|
@ -146,7 +142,6 @@ describe("WebSocketManager", () => {
|
|||
|
||||
it("cleans up cursor position promises", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
|
|
@ -176,7 +171,6 @@ describe("WebSocketManager", () => {
|
|||
|
||||
it("logs handshake send errors", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
|
|
@ -205,7 +199,6 @@ describe("WebSocketManager", () => {
|
|||
|
||||
it("completes stop with timeout protection", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
|
|
@ -220,7 +213,6 @@ describe("WebSocketManager", () => {
|
|||
|
||||
it("clears old handlers on reconnection", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
|
|
@ -255,9 +247,68 @@ describe("WebSocketManager", () => {
|
|||
await manager.stop();
|
||||
});
|
||||
|
||||
it("handles concurrent stop() calls without stranding either caller", async () => {
|
||||
// Real WebSocket.close() doesn't fire onclose synchronously, and the
|
||||
// socket stays reachable across the close handshake. Model that
|
||||
// here so the manager's `while (isWebSocketConnected)` loop is
|
||||
// actually awaiting when the second stop() races in. Static OPEN
|
||||
// is required because the manager compares readyState against
|
||||
// `factory.OPEN`.
|
||||
class AsyncCloseWebSocket extends MockWebSocket {
|
||||
public static readonly OPEN = WebSocket.OPEN;
|
||||
|
||||
public override close(code?: number, reason?: string): void {
|
||||
if (
|
||||
this.readyState === WebSocket.CLOSED ||
|
||||
(this as { _closing?: boolean })._closing === true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
(this as { _closing?: boolean })._closing = true;
|
||||
setTimeout(() => {
|
||||
this.readyState = WebSocket.CLOSED;
|
||||
this.onclose?.(
|
||||
new MockCloseEvent("close", {
|
||||
code: code ?? 1000,
|
||||
reason: reason ?? ""
|
||||
})
|
||||
);
|
||||
}, 5);
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new WebSocketManager(
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
AsyncCloseWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
|
||||
manager.start();
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const start = Date.now();
|
||||
// Two concurrent stops mimic destroy() racing onSettingsChange.
|
||||
await awaitAll([manager.stop(), manager.stop()]);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// Both should resolve via the normal close path; if the second call
|
||||
// had clobbered the first's resolver, the first would have been
|
||||
// stranded until the 10s disconnect timeout.
|
||||
assert.ok(
|
||||
elapsed < 1000,
|
||||
`concurrent stop() took ${elapsed}ms — expected fast resolution`
|
||||
);
|
||||
const errorCalls = (mockLogger.error as unknown as { calls: unknown[] })
|
||||
.calls;
|
||||
assert.strictEqual(
|
||||
errorCalls.length,
|
||||
0,
|
||||
"no timeout-recovery error should be logged"
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks message handling promises", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@ import type { WebSocketServerMessage } from "./types/WebSocketServerMessage";
|
|||
import type { WebSocketClientMessage } from "./types/WebSocketClientMessage";
|
||||
import type { CursorPositionFromClient } from "./types/CursorPositionFromClient";
|
||||
import type { ClientCursors } from "./types/ClientCursors";
|
||||
import { createPromise } from "../utils/create-promise";
|
||||
import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate";
|
||||
import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_S } from "../consts";
|
||||
import {
|
||||
WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS,
|
||||
WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS
|
||||
} from "../consts";
|
||||
import { removeFromArray } from "../utils/remove-from-array";
|
||||
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||
import { awaitAll } from "../utils/await-all";
|
||||
import { buildVaultUrl } from "./build-vault-url";
|
||||
|
||||
export class WebSocketManager {
|
||||
public readonly onWebSocketStatusChanged = new EventListeners<
|
||||
|
|
@ -26,32 +29,22 @@ export class WebSocketManager {
|
|||
|
||||
private isStopped = true;
|
||||
private resolveDisconnectingPromise: null | (() => unknown) = null;
|
||||
private stopPromise: Promise<void> | null = null;
|
||||
private reconnectTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
private connectionTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
private readonly outstandingPromises: Promise<unknown>[] = [];
|
||||
|
||||
private webSocket: WebSocket | undefined;
|
||||
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket;
|
||||
|
||||
public constructor(
|
||||
private readonly deviceId: string,
|
||||
private readonly logger: Logger,
|
||||
private readonly settings: Settings,
|
||||
webSocketImplementation?: typeof globalThis.WebSocket
|
||||
) {
|
||||
if (webSocketImplementation) {
|
||||
this.webSocketFactoryImplementation = webSocketImplementation;
|
||||
} else {
|
||||
if (
|
||||
typeof globalThis !== "undefined" &&
|
||||
typeof globalThis.WebSocket === "undefined"
|
||||
) {
|
||||
// eslint-disable-next-line
|
||||
this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js
|
||||
} else {
|
||||
this.webSocketFactoryImplementation = WebSocket;
|
||||
}
|
||||
}
|
||||
private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket = WebSocket
|
||||
) {}
|
||||
|
||||
public get hasOutstandingWork(): boolean {
|
||||
return this.outstandingPromises.length > 0;
|
||||
}
|
||||
|
||||
public get isWebSocketConnected(): boolean {
|
||||
|
|
@ -67,49 +60,14 @@ export class WebSocketManager {
|
|||
}
|
||||
|
||||
public async stop(): Promise<void> {
|
||||
const [promise, resolve] = createPromise();
|
||||
this.resolveDisconnectingPromise = resolve;
|
||||
|
||||
this.isStopped = true;
|
||||
|
||||
if (this.reconnectTimeoutId !== undefined) {
|
||||
clearTimeout(this.reconnectTimeoutId);
|
||||
this.reconnectTimeoutId = undefined;
|
||||
}
|
||||
|
||||
this.webSocket?.close(1000, "WebSocketManager has been stopped");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds`
|
||||
)
|
||||
);
|
||||
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000);
|
||||
// Concurrent callers (e.g. destroy() and onSettingsChange) must share
|
||||
// the same disconnect; otherwise the second call would overwrite
|
||||
// resolveDisconnectingPromise and strand the first caller's await
|
||||
// until the timeout rejects.
|
||||
this.stopPromise ??= this.performStop().finally(() => {
|
||||
this.stopPromise = null;
|
||||
});
|
||||
|
||||
try {
|
||||
while (this.isWebSocketConnected) {
|
||||
await Promise.race([promise, timeoutPromise]);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error while waiting for WebSocket to close: ${String(error)}`
|
||||
);
|
||||
// Force cleanup even if close didn't work
|
||||
this.resolveDisconnectingPromise();
|
||||
this.resolveDisconnectingPromise = null;
|
||||
} finally {
|
||||
// Clear timeout to prevent unhandled rejection
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
await this.waitUntilFinished();
|
||||
await this.stopPromise;
|
||||
}
|
||||
|
||||
public async waitUntilFinished(): Promise<void> {
|
||||
|
|
@ -162,6 +120,59 @@ export class WebSocketManager {
|
|||
}
|
||||
}
|
||||
|
||||
private async performStop(): Promise<void> {
|
||||
const { promise, resolve } = Promise.withResolvers<undefined>();
|
||||
this.resolveDisconnectingPromise = (): void => {
|
||||
resolve(undefined);
|
||||
};
|
||||
|
||||
this.isStopped = true;
|
||||
|
||||
if (this.reconnectTimeoutId !== undefined) {
|
||||
clearTimeout(this.reconnectTimeoutId);
|
||||
this.reconnectTimeoutId = undefined;
|
||||
}
|
||||
|
||||
if (this.connectionTimeoutId !== undefined) {
|
||||
clearTimeout(this.connectionTimeoutId);
|
||||
this.connectionTimeoutId = undefined;
|
||||
}
|
||||
|
||||
this.webSocket?.close(1000, "WebSocketManager has been stopped");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/init-declarations
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<void>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(
|
||||
new Error(
|
||||
`Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS} seconds`
|
||||
)
|
||||
);
|
||||
}, WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS * 1000);
|
||||
});
|
||||
|
||||
try {
|
||||
while (this.isWebSocketConnected) {
|
||||
await Promise.race([promise, timeoutPromise]);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error while waiting for WebSocket to close: ${String(error)}`
|
||||
);
|
||||
// Force cleanup even if close didn't work
|
||||
this.resolveDisconnectingPromise();
|
||||
this.resolveDisconnectingPromise = null;
|
||||
} finally {
|
||||
// Clear timeout to prevent unhandled rejection
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
await this.waitUntilFinished();
|
||||
}
|
||||
|
||||
private initializeWebSocket(): void {
|
||||
// Clean up old WebSocket handlers to prevent race conditions
|
||||
if (this.webSocket) {
|
||||
|
|
@ -171,26 +182,55 @@ export class WebSocketManager {
|
|||
this.webSocket.onclose = null;
|
||||
this.webSocket.onmessage = null;
|
||||
this.webSocket.onerror = null;
|
||||
this.webSocket.close();
|
||||
this.webSocket.close(
|
||||
1000,
|
||||
"Closing previous WebSocket connection"
|
||||
);
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`Failed to close previous WebSocket connection: ${e}`
|
||||
);
|
||||
}
|
||||
// Abandon any outstanding handler promises from the previous
|
||||
// connection. They'll still resolve in the background, but we
|
||||
// no longer want `waitUntilFinished` / `stop` to block on
|
||||
// post-reconnect state — and we definitely don't want their
|
||||
// results applied against a now-stale socket.
|
||||
this.outstandingPromises.length = 0;
|
||||
}
|
||||
|
||||
const wsUri = new URL(this.settings.getSettings().remoteUri);
|
||||
wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws";
|
||||
wsUri.pathname = `/vaults/${this.settings.getSettings().vaultName}/ws`;
|
||||
// Build the WS URL through the same vault-URL helper the HTTP client
|
||||
// uses so vault-name encoding, trailing-slash stripping, and any path
|
||||
// prefix in `remoteUri` stay in sync between transports.
|
||||
const wsUri = new URL(buildVaultUrl(this.settings, "/ws"));
|
||||
wsUri.protocol = wsUri.protocol.startsWith("https") ? "wss" : "ws";
|
||||
|
||||
this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`);
|
||||
|
||||
this.webSocket = new this.webSocketFactoryImplementation(wsUri);
|
||||
const ws = new this.webSocketFactoryImplementation(wsUri);
|
||||
this.webSocket = ws;
|
||||
|
||||
// Set connection timeout to handle cases where server is down and the WebSocket connection won't open.
|
||||
// The callback closes the *captured* `ws` rather than `this.webSocket` so a delayed timeout cannot
|
||||
// accidentally close a freshly-constructed replacement socket. (Closing the already-closed `ws` is a no-op.)
|
||||
this.connectionTimeoutId = setTimeout(() => {
|
||||
this.connectionTimeoutId = undefined;
|
||||
this.logger.warn(
|
||||
`WebSocket connection timeout after ${WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS} seconds`
|
||||
);
|
||||
// Force close to trigger onclose handler which will schedule reconnection
|
||||
ws.close(1000, "Connection timeout");
|
||||
}, WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS * 1000);
|
||||
|
||||
ws.onopen = (): void => {
|
||||
if (this.connectionTimeoutId !== undefined) {
|
||||
clearTimeout(this.connectionTimeoutId);
|
||||
this.connectionTimeoutId = undefined;
|
||||
}
|
||||
|
||||
this.webSocket.onopen = (): void => {
|
||||
// Check if we've been stopped while connecting
|
||||
if (this.isStopped) {
|
||||
this.webSocket?.close(
|
||||
ws.close(
|
||||
1000,
|
||||
"WebSocketManager was stopped during connection"
|
||||
);
|
||||
|
|
@ -200,7 +240,7 @@ export class WebSocketManager {
|
|||
this.onWebSocketStatusChanged.trigger(true);
|
||||
};
|
||||
|
||||
this.webSocket.onmessage = (event): void => {
|
||||
ws.onmessage = (event): void => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const message = JSON.parse(
|
||||
|
|
@ -231,7 +271,18 @@ export class WebSocketManager {
|
|||
}
|
||||
};
|
||||
|
||||
this.webSocket.onclose = (event): void => {
|
||||
ws.onerror = (error): void => {
|
||||
this.logger.warn(
|
||||
`WebSocket error occurred: ${error instanceof ErrorEvent ? error.message : "Unknown error"}`
|
||||
);
|
||||
};
|
||||
|
||||
ws.onclose = (event): void => {
|
||||
if (this.connectionTimeoutId !== undefined) {
|
||||
clearTimeout(this.connectionTimeoutId);
|
||||
this.connectionTimeoutId = undefined;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})`
|
||||
);
|
||||
|
|
@ -241,10 +292,13 @@ export class WebSocketManager {
|
|||
this.resolveDisconnectingPromise?.();
|
||||
this.resolveDisconnectingPromise = null;
|
||||
} else {
|
||||
const delay =
|
||||
this.settings.getSettings().webSocketRetryIntervalMs;
|
||||
this.logger.info(`Reconnecting to WebSocket in ${delay}ms...`);
|
||||
this.reconnectTimeoutId = setTimeout(() => {
|
||||
this.reconnectTimeoutId = undefined;
|
||||
this.initializeWebSocket();
|
||||
}, this.settings.getSettings().webSocketRetryIntervalMs);
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -252,22 +306,22 @@ export class WebSocketManager {
|
|||
private async handleWebSocketMessage(
|
||||
message: WebSocketServerMessage
|
||||
): Promise<void> {
|
||||
if (message.type === "vaultUpdate") {
|
||||
await this.onRemoteVaultUpdateReceived.triggerAsync(message);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
} else if (message.type === "cursorPositions") {
|
||||
this.logger.debug(
|
||||
`Received cursor positions for ${JSON.stringify(message.clients)}`
|
||||
);
|
||||
|
||||
await this.onRemoteCursorsUpdateReceived.triggerAsync(
|
||||
message.clients
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Received unknown message type: ${JSON.stringify(message)}`
|
||||
);
|
||||
switch (message.type) {
|
||||
case "vaultUpdate":
|
||||
await this.onRemoteVaultUpdateReceived.triggerAsync(message);
|
||||
return;
|
||||
case "cursorPositions":
|
||||
this.logger.debug(
|
||||
`Received cursor positions for ${JSON.stringify(message.clients)}`
|
||||
);
|
||||
await this.onRemoteCursorsUpdateReceived.triggerAsync(
|
||||
message.clients
|
||||
);
|
||||
return;
|
||||
default:
|
||||
this.logger.warn(
|
||||
`Received unknown message type: ${JSON.stringify(message)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@ import type { PersistenceProvider } from "./persistence/persistence";
|
|||
import type { HistoryEntry, HistoryStats } from "./tracing/sync-history";
|
||||
import { SyncHistory } from "./tracing/sync-history";
|
||||
import { Logger, LogLevel, LogLine } from "./tracing/logger";
|
||||
import type { RelativePath, StoredDatabase } from "./persistence/database";
|
||||
import { Database } from "./persistence/database";
|
||||
import type {
|
||||
DocumentId,
|
||||
RelativePath,
|
||||
StoredSyncState
|
||||
} from "./sync-operations/types";
|
||||
import { SyncEventQueue } from "./sync-operations/sync-event-queue";
|
||||
import * as Sentry from "@sentry/browser";
|
||||
import type { SyncSettings } from "./persistence/settings";
|
||||
import { DEFAULT_SETTINGS, Settings } from "./persistence/settings";
|
||||
|
|
@ -12,7 +16,6 @@ import { Syncer } from "./sync-operations/syncer";
|
|||
import type { FileSystemOperations } from "./file-operations/filesystem-operations";
|
||||
import { FileOperations } from "./file-operations/file-operations";
|
||||
import { FetchController } from "./services/fetch-controller";
|
||||
import { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer";
|
||||
import { rateLimit } from "./utils/rate-limit";
|
||||
import type { NetworkConnectionStatus } from "./types/network-connection-status";
|
||||
import { DocumentSyncStatus } from "./types/document-sync-status";
|
||||
|
|
@ -24,42 +27,46 @@ import type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-c
|
|||
import { FileChangeNotifier } from "./sync-operations/file-change-notifier";
|
||||
import { FixedSizeDocumentCache } from "./utils/data-structures/fix-sized-cache";
|
||||
import { setUpTelemetry } from "./utils/set-up-telemetry";
|
||||
import { DIFF_CACHE_SIZE_MB } from "./consts";
|
||||
import { ServerConfig } from "./services/server-config";
|
||||
import type { EventListeners } from "./utils/data-structures/event-listeners";
|
||||
import { Lock } from "./utils/data-structures/locks";
|
||||
import { ExpectedFsEvents } from "./sync-operations/expected-fs-events";
|
||||
|
||||
export class SyncClient {
|
||||
private hasStartedOfflineSync = false;
|
||||
private hasFinishedOfflineSync = false;
|
||||
private hasStarted = false;
|
||||
private hasBeenDestroyed = false;
|
||||
private unloadTelemetry?: () => void;
|
||||
private isDestroying = false;
|
||||
private readonly eventUnsubscribers: (() => void)[] = [];
|
||||
private readonly settingsChangeLock = new Lock(
|
||||
"SyncClient.onSettingsChange"
|
||||
);
|
||||
|
||||
private constructor(
|
||||
public readonly logger: Logger,
|
||||
private readonly history: SyncHistory,
|
||||
private readonly settings: Settings,
|
||||
private readonly database: Database,
|
||||
private readonly syncEventQueue: SyncEventQueue,
|
||||
private readonly syncer: Syncer,
|
||||
private readonly webSocketManager: WebSocketManager,
|
||||
public readonly logger: Logger,
|
||||
private readonly fetchController: FetchController,
|
||||
private readonly cursorTracker: CursorTracker,
|
||||
private readonly fileChangeNotifier: FileChangeNotifier,
|
||||
private readonly contentCache: FixedSizeDocumentCache,
|
||||
private readonly fileOperations: FileOperations,
|
||||
private readonly serverConfig: ServerConfig,
|
||||
private readonly syncService: SyncService,
|
||||
private readonly expectedFsEvents: ExpectedFsEvents,
|
||||
private readonly persistence: PersistenceProvider<
|
||||
Partial<{
|
||||
settings: Partial<SyncSettings>;
|
||||
database: Partial<StoredDatabase>;
|
||||
database: Partial<StoredSyncState>;
|
||||
}>
|
||||
>
|
||||
) {}
|
||||
|
||||
public get documentCount(): number {
|
||||
return this.database.length;
|
||||
public get syncedDocumentCount(): number {
|
||||
return this.syncEventQueue.syncedDocumentCount;
|
||||
}
|
||||
|
||||
public get isWebSocketConnected(): boolean {
|
||||
|
|
@ -73,6 +80,27 @@ export class SyncClient {
|
|||
return this.history.onHistoryUpdated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires whenever a tracked document's local file moves on disk —
|
||||
* watcher-driven user renames, post-create deconflicts placed by
|
||||
* the reconciler, lost-rename replays in offline scan, slot
|
||||
* displacements when another record claims a path. Both
|
||||
* `oldPath` and `newPath` may be `undefined` (placement-pending
|
||||
* state). Useful for callers that mirror disk-side path state
|
||||
* — e.g. test harnesses tracking which paths are safe to mutate
|
||||
* — and need a signal beyond the user-facing history.
|
||||
*/
|
||||
public get onDocumentPathChanged(): EventListeners<
|
||||
(
|
||||
documentId: DocumentId,
|
||||
oldPath: RelativePath | undefined,
|
||||
newPath: RelativePath | undefined
|
||||
) => unknown
|
||||
> {
|
||||
this.checkIfDestroyed("onDocumentPathChanged getter");
|
||||
return this.syncEventQueue.onDocumentPathChanged;
|
||||
}
|
||||
|
||||
public get onSettingsChanged(): EventListeners<
|
||||
(newSettings: SyncSettings, oldSettings: SyncSettings) => unknown
|
||||
> {
|
||||
|
|
@ -101,6 +129,13 @@ export class SyncClient {
|
|||
return this.cursorTracker.onRemoteCursorsUpdated;
|
||||
}
|
||||
|
||||
public get hasPendingWork(): boolean {
|
||||
return (
|
||||
this.syncEventQueue.pendingUpdateCount > 0 ||
|
||||
this.webSocketManager.hasOutstandingWork
|
||||
);
|
||||
}
|
||||
|
||||
public static async create({
|
||||
fs,
|
||||
persistence,
|
||||
|
|
@ -112,7 +147,8 @@ export class SyncClient {
|
|||
persistence: PersistenceProvider<
|
||||
Partial<{
|
||||
settings: Partial<SyncSettings>;
|
||||
database: Partial<StoredDatabase>;
|
||||
database: Partial<StoredSyncState>;
|
||||
deviceId: string;
|
||||
}>
|
||||
>;
|
||||
fetch?: typeof globalThis.fetch;
|
||||
|
|
@ -121,39 +157,46 @@ export class SyncClient {
|
|||
}): Promise<SyncClient> {
|
||||
const logger = new Logger();
|
||||
|
||||
const deviceId = createClientId();
|
||||
|
||||
logger.info(`Creating SyncClient with client id ${deviceId}`);
|
||||
|
||||
const history = new SyncHistory(logger);
|
||||
|
||||
let state = (await persistence.load()) ?? {
|
||||
settings: undefined,
|
||||
database: undefined
|
||||
database: undefined,
|
||||
deviceId: undefined
|
||||
};
|
||||
|
||||
// Persist deviceId across destroy + init so the server's
|
||||
// lost-create dedup (which scopes by device_id) can recognise
|
||||
// a retry as belonging to the same client. Without this,
|
||||
// every fresh `SyncClient` after a destroy would generate a
|
||||
// new deviceId, the server-side query would miss, and the
|
||||
// pending-but-lost create would deconflict instead of
|
||||
// binding to the doc its content was already absorbed into.
|
||||
let deviceId = state.deviceId;
|
||||
if (deviceId === undefined) {
|
||||
deviceId = createClientId();
|
||||
state = { ...state, deviceId };
|
||||
await persistence.save(state);
|
||||
}
|
||||
|
||||
logger.info(`Creating SyncClient with client id ${deviceId}`);
|
||||
|
||||
const settings = new Settings(
|
||||
logger,
|
||||
state.settings,
|
||||
async (data): Promise<void> => {
|
||||
state = { ...state, settings: data };
|
||||
// we're not rate-limiting settings saves as (1) we need to initialise the settings to know the rate limit
|
||||
// and (2) settings changes are infrequent enough that rate-limiting is not necessary
|
||||
await persistence.save(state);
|
||||
}
|
||||
);
|
||||
|
||||
const rateLimitedSave = rateLimit(
|
||||
persistence.save,
|
||||
() => settings.getSettings().minimumSaveIntervalMs
|
||||
);
|
||||
|
||||
const database = new Database(
|
||||
const syncEventQueue = new SyncEventQueue(
|
||||
settings,
|
||||
logger,
|
||||
state.database,
|
||||
async (data): Promise<void> => {
|
||||
state = { ...state, database: data };
|
||||
await rateLimitedSave(state);
|
||||
await persistence.save(state);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -170,32 +213,23 @@ export class SyncClient {
|
|||
fetch
|
||||
);
|
||||
|
||||
const serverConfig = new ServerConfig(syncService);
|
||||
const serverConfig = new ServerConfig(syncService, settings);
|
||||
|
||||
const expectedFsEvents = new ExpectedFsEvents();
|
||||
|
||||
const fileOperations = new FileOperations(
|
||||
logger,
|
||||
database,
|
||||
fs,
|
||||
serverConfig,
|
||||
expectedFsEvents,
|
||||
nativeLineEndings
|
||||
);
|
||||
|
||||
const contentCache = new FixedSizeDocumentCache(
|
||||
1024 * 1024 * DIFF_CACHE_SIZE_MB
|
||||
);
|
||||
const unrestrictedSyncer = new UnrestrictedSyncer(
|
||||
logger,
|
||||
database,
|
||||
settings,
|
||||
syncService,
|
||||
fileOperations,
|
||||
history,
|
||||
contentCache,
|
||||
serverConfig
|
||||
1024 * 1024 * settings.getSettings().diffCacheSizeMB
|
||||
);
|
||||
|
||||
const webSocketManager = new WebSocketManager(
|
||||
deviceId,
|
||||
logger,
|
||||
settings,
|
||||
webSocket
|
||||
|
|
@ -204,34 +238,38 @@ export class SyncClient {
|
|||
const syncer = new Syncer(
|
||||
deviceId,
|
||||
logger,
|
||||
database,
|
||||
settings,
|
||||
syncService,
|
||||
webSocketManager,
|
||||
fileOperations,
|
||||
unrestrictedSyncer
|
||||
syncService,
|
||||
history,
|
||||
contentCache,
|
||||
serverConfig,
|
||||
syncEventQueue
|
||||
);
|
||||
|
||||
const fileChangeNotifier = new FileChangeNotifier();
|
||||
const cursorTracker = new CursorTracker(
|
||||
database,
|
||||
logger,
|
||||
syncEventQueue,
|
||||
webSocketManager,
|
||||
fileOperations,
|
||||
fileChangeNotifier
|
||||
);
|
||||
const client = new SyncClient(
|
||||
logger,
|
||||
history,
|
||||
settings,
|
||||
database,
|
||||
syncEventQueue,
|
||||
syncer,
|
||||
webSocketManager,
|
||||
logger,
|
||||
fetchController,
|
||||
cursorTracker,
|
||||
fileChangeNotifier,
|
||||
contentCache,
|
||||
fileOperations,
|
||||
serverConfig,
|
||||
syncService,
|
||||
expectedFsEvents,
|
||||
persistence
|
||||
);
|
||||
|
||||
|
|
@ -285,10 +323,10 @@ export class SyncClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* Reload settings from disk overriding current in-memory settings.
|
||||
* Missing values will be filled in from DEFAULT_SETTINGS rather than
|
||||
* retaining current in-memory settings.
|
||||
*/
|
||||
* Reload settings from disk overriding current in-memory settings.
|
||||
* Missing values will be filled in from DEFAULT_SETTINGS rather than
|
||||
* retaining current in-memory settings.
|
||||
*/
|
||||
public async reloadSettings(): Promise<void> {
|
||||
this.checkIfDestroyed("reloadSettings");
|
||||
|
||||
|
|
@ -320,10 +358,10 @@ export class SyncClient {
|
|||
}
|
||||
|
||||
/**
|
||||
* Wait for the in-flight operations to finish, reset all tracking,
|
||||
* and the local database but retain the settings.
|
||||
* The SyncClient can be used again after calling this method.
|
||||
*/
|
||||
* Wait for the in-flight operations to finish, reset all tracking,
|
||||
* and the local state but retain the settings.
|
||||
* The SyncClient can be used again after calling this method.
|
||||
*/
|
||||
public async reset(): Promise<void> {
|
||||
this.checkIfDestroyed("reset");
|
||||
|
||||
|
|
@ -332,16 +370,16 @@ export class SyncClient {
|
|||
);
|
||||
await this.pause();
|
||||
|
||||
// clear all local state
|
||||
this.logger.info("Resetting SyncClient's local state");
|
||||
this.database.reset();
|
||||
await this.database.save(); // ensure the new database reads as empty
|
||||
await this.syncEventQueue.clearAllState();
|
||||
await this.syncEventQueue.save();
|
||||
this.resetInMemoryState();
|
||||
this.hasStartedOfflineSync = false;
|
||||
this.hasFinishedOfflineSync = false;
|
||||
this.serverConfig.reset();
|
||||
|
||||
await this.startSyncing();
|
||||
if (this.settings.getSettings().isSyncEnabled) {
|
||||
await this.startSyncing();
|
||||
}
|
||||
}
|
||||
|
||||
public getSettings(): SyncSettings {
|
||||
|
|
@ -363,40 +401,48 @@ export class SyncClient {
|
|||
await this.settings.setSettings(value);
|
||||
}
|
||||
|
||||
public async syncLocallyCreatedFile(
|
||||
relativePath: RelativePath
|
||||
): Promise<void> {
|
||||
public syncLocallyCreatedFile(relativePath: RelativePath): void {
|
||||
this.checkIfDestroyed("syncLocallyCreatedFile");
|
||||
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||
return this.syncer.syncLocallyCreatedFile(relativePath);
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
|
||||
if (this.expectedFsEvents.matchCreate(relativePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncer.syncLocallyCreatedFile(relativePath);
|
||||
}
|
||||
|
||||
public async syncLocallyDeletedFile(
|
||||
relativePath: RelativePath
|
||||
): Promise<void> {
|
||||
this.checkIfDestroyed("syncLocallyDeletedFile");
|
||||
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||
return this.syncer.syncLocallyDeletedFile(relativePath);
|
||||
}
|
||||
|
||||
public async syncLocallyUpdatedFile({
|
||||
public syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath
|
||||
}: {
|
||||
oldPath?: RelativePath;
|
||||
relativePath: RelativePath;
|
||||
}): Promise<void> {
|
||||
}): void {
|
||||
this.checkIfDestroyed("syncLocallyUpdatedFile");
|
||||
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath);
|
||||
return this.syncer.syncLocallyUpdatedFile({
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
|
||||
if (this.expectedFsEvents.matchUpdate(relativePath, oldPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncer.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath
|
||||
});
|
||||
}
|
||||
|
||||
public syncLocallyDeletedFile(relativePath: RelativePath): void {
|
||||
this.checkIfDestroyed("syncLocallyDeletedFile");
|
||||
|
||||
this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors
|
||||
if (this.expectedFsEvents.matchDelete(relativePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncer.syncLocallyDeletedFile(relativePath);
|
||||
}
|
||||
|
||||
public getDocumentSyncingStatus(
|
||||
relativePath: RelativePath
|
||||
): DocumentSyncStatus {
|
||||
|
|
@ -406,16 +452,11 @@ export class SyncClient {
|
|||
return DocumentSyncStatus.SYNCING_IS_DISABLED;
|
||||
}
|
||||
|
||||
if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) {
|
||||
if (!this.hasFinishedOfflineSync) {
|
||||
return DocumentSyncStatus.SYNCING;
|
||||
}
|
||||
|
||||
const document =
|
||||
this.database.getLatestDocumentByRelativePath(relativePath);
|
||||
if (document === undefined) {
|
||||
return DocumentSyncStatus.SYNCING;
|
||||
}
|
||||
return document.updates.length > 0
|
||||
return this.syncEventQueue.hasPendingEventsForPath(relativePath)
|
||||
? DocumentSyncStatus.SYNCING
|
||||
: DocumentSyncStatus.UP_TO_DATE;
|
||||
}
|
||||
|
|
@ -429,20 +470,20 @@ export class SyncClient {
|
|||
}
|
||||
|
||||
public async waitUntilFinished(): Promise<void> {
|
||||
this.checkIfDestroyed("waitUntilIdle");
|
||||
await this.syncer.waitUntilFinished();
|
||||
await this.webSocketManager.waitUntilFinished();
|
||||
await this.database.save(); // flush all changes to disk
|
||||
this.checkIfDestroyed("waitUntilFinished");
|
||||
await this.waitUntilFinishedInternal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Completely destroy the SyncClient, cancelling all in-progress operations.
|
||||
* After calling this method, the SyncClient cannot be used again.
|
||||
*/
|
||||
* Completely destroy the SyncClient, cancelling all in-progress operations.
|
||||
* After calling this method, the SyncClient cannot be used again.
|
||||
*/
|
||||
public async destroy(): Promise<void> {
|
||||
this.checkIfDestroyed("destroy");
|
||||
|
||||
// Prevent concurrent destroy calls
|
||||
if (this.hasBeenDestroyed) {
|
||||
throw new Error(
|
||||
"SyncClient has been destroyed and can no longer be used; called from destroy"
|
||||
);
|
||||
}
|
||||
if (this.isDestroying) {
|
||||
this.logger.warn(
|
||||
"destroy() called while already destroying, ignoring"
|
||||
|
|
@ -451,52 +492,92 @@ export class SyncClient {
|
|||
}
|
||||
this.isDestroying = true;
|
||||
|
||||
// cancel everything that's in progress
|
||||
await this.pause();
|
||||
// Run cleanup in `finally` so a thrown pause() — or anything else
|
||||
// mid-shutdown — still leaves the client in the disposed state
|
||||
// instead of bricked with subscribers/telemetry hanging on.
|
||||
try {
|
||||
await this.pause();
|
||||
} finally {
|
||||
this.hasBeenDestroyed = true;
|
||||
|
||||
this.hasBeenDestroyed = true;
|
||||
this.resetInMemoryState();
|
||||
|
||||
this.resetInMemoryState();
|
||||
this.eventUnsubscribers.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
this.eventUnsubscribers.length = 0;
|
||||
|
||||
// Clean up event listeners to prevent memory leaks
|
||||
this.eventUnsubscribers.forEach((unsubscribe) => {
|
||||
unsubscribe();
|
||||
});
|
||||
this.eventUnsubscribers.length = 0;
|
||||
this.logger.info("SyncClient has been successfully disposed");
|
||||
|
||||
this.logger.info("SyncClient has been successfully disposed");
|
||||
this.unloadTelemetry?.();
|
||||
}
|
||||
}
|
||||
|
||||
this.unloadTelemetry?.();
|
||||
/**
|
||||
* The actual drain — separated from `waitUntilFinished` so internal
|
||||
* shutdown paths (`pause` / `destroy`) can wait for in-flight work
|
||||
* without tripping the public `checkIfDestroyed` guard, which exists
|
||||
* only to keep external callers from continuing to use a disposed
|
||||
* client.
|
||||
*
|
||||
* Loops because a WebSocket message handler completing is what enqueues
|
||||
* a `RemoteChange` into the syncer; if we awaited the syncer first and
|
||||
* the WS handler second, a message arriving mid-wait would leave a fresh
|
||||
* drain pending while `save()` ran. Each iteration waits for both, then
|
||||
* re-checks; we exit only once both report idle in the same pass.
|
||||
*/
|
||||
private async waitUntilFinishedInternal(): Promise<void> {
|
||||
while (
|
||||
this.webSocketManager.hasOutstandingWork ||
|
||||
this.syncer.hasPendingWork
|
||||
) {
|
||||
await this.webSocketManager.waitUntilFinished();
|
||||
await this.syncer.waitUntilFinished();
|
||||
}
|
||||
await this.syncEventQueue.save();
|
||||
}
|
||||
|
||||
private async startSyncing(): Promise<void> {
|
||||
this.checkIfDestroyed("startSyncing");
|
||||
this.fetchController.finishReset();
|
||||
// Undo any earlier `pause()` stop so retryForever keeps retrying.
|
||||
this.syncService.resume();
|
||||
|
||||
await this.serverConfig.initialize();
|
||||
await this.serverConfig.getConfig();
|
||||
|
||||
await this.syncer.scheduleSyncForOfflineChanges();
|
||||
this.syncer.resumeDraining();
|
||||
this.webSocketManager.start();
|
||||
|
||||
if (!this.hasStartedOfflineSync) {
|
||||
this.hasStartedOfflineSync = true;
|
||||
await this.syncer.scheduleSyncForOfflineChanges();
|
||||
}
|
||||
|
||||
this.hasFinishedOfflineSync = true;
|
||||
}
|
||||
|
||||
private async pause(): Promise<void> {
|
||||
this.hasFinishedOfflineSync = false;
|
||||
this.syncer.pauseDraining();
|
||||
this.fetchController.startReset();
|
||||
// Signal the service so any `retryForever` loop exits at its next
|
||||
// iteration instead of continuing to retry a network request while
|
||||
// the rest of the client is winding down.
|
||||
this.syncService.stop();
|
||||
await this.webSocketManager.stop();
|
||||
await this.waitUntilFinished();
|
||||
await this.waitUntilFinishedInternal();
|
||||
// Clear the offline-scan gate so a subsequent `startSyncing()`
|
||||
// re-runs the scan; otherwise any local changes made while sync was
|
||||
// paused (offline edits, deletes, renames) wouldn't be detected, and
|
||||
// an incoming remote update would silently overwrite them.
|
||||
this.syncer.clearOfflineScanGate();
|
||||
// Drop any expected fs events that were registered but never matched
|
||||
// (e.g. an op aborted by SyncResetError). Otherwise a real user edit
|
||||
// at the same path after re-enable would be swallowed.
|
||||
this.expectedFsEvents.clear();
|
||||
}
|
||||
|
||||
private resetInMemoryState(): void {
|
||||
this.history.reset();
|
||||
this.contentCache.reset();
|
||||
// don't reset the logger
|
||||
this.cursorTracker.reset();
|
||||
this.syncer.reset();
|
||||
this.fileOperations.reset();
|
||||
}
|
||||
|
||||
private async onSettingsChange(
|
||||
|
|
@ -505,36 +586,55 @@ export class SyncClient {
|
|||
): Promise<void> {
|
||||
this.checkIfDestroyed("onSettingsChange");
|
||||
|
||||
if (
|
||||
newSettings.vaultName !== oldSettings.vaultName ||
|
||||
newSettings.remoteUri !== oldSettings.remoteUri
|
||||
) {
|
||||
await this.reset();
|
||||
}
|
||||
|
||||
if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) {
|
||||
if (newSettings.isSyncEnabled) {
|
||||
await this.startSyncing();
|
||||
} else {
|
||||
await this.pause();
|
||||
// Serialize listener invocations so back-to-back settings updates
|
||||
// can't run reset()/pause()/startSyncing() concurrently.
|
||||
await this.settingsChangeLock.withLock(async () => {
|
||||
// The lock is FIFO, so by the time we run the client may have
|
||||
// been destroyed in a queued invocation ahead of us.
|
||||
if (this.hasBeenDestroyed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) {
|
||||
this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024);
|
||||
}
|
||||
const connectionChanged =
|
||||
newSettings.vaultName !== oldSettings.vaultName ||
|
||||
newSettings.remoteUri !== oldSettings.remoteUri;
|
||||
|
||||
if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) {
|
||||
if (newSettings.enableTelemetry) {
|
||||
this.unloadTelemetry = setUpTelemetry();
|
||||
} else {
|
||||
this.unloadTelemetry?.();
|
||||
if (connectionChanged) {
|
||||
// reset() pauses, clears state, then starts iff isSyncEnabled
|
||||
// — so any concurrent isSyncEnabled change is already applied.
|
||||
await this.reset();
|
||||
} else if (
|
||||
newSettings.isSyncEnabled !== oldSettings.isSyncEnabled
|
||||
) {
|
||||
if (newSettings.isSyncEnabled) {
|
||||
await this.startSyncing();
|
||||
} else {
|
||||
await this.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) {
|
||||
this.contentCache.resize(
|
||||
newSettings.diffCacheSizeMB * 1024 * 1024
|
||||
);
|
||||
}
|
||||
|
||||
if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) {
|
||||
if (newSettings.enableTelemetry) {
|
||||
this.unloadTelemetry = setUpTelemetry();
|
||||
} else {
|
||||
this.unloadTelemetry?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private checkIfDestroyed(origin: string): void {
|
||||
if (this.hasBeenDestroyed) {
|
||||
// Reject new public-API entries the moment destroy() is called,
|
||||
// not after `pause()` returns. Otherwise an external caller could
|
||||
// pass the guard and start mutating state while destroy() is
|
||||
// tearing down the websocket / clearing caches.
|
||||
if (this.hasBeenDestroyed || this.isDestroying) {
|
||||
throw new Error(
|
||||
`SyncClient has been destroyed and can no longer be used; called from ${origin}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { FileOperations } from "../file-operations/file-operations";
|
||||
import type { Database, RelativePath } from "../persistence/database";
|
||||
import type { RelativePath } from "./types";
|
||||
import type { SyncEventQueue } from "./sync-event-queue";
|
||||
import type { ClientCursors } from "../services/types/ClientCursors";
|
||||
import type { CursorSpan } from "../services/types/CursorSpan";
|
||||
import type { DocumentWithCursors } from "../services/types/DocumentWithCursors";
|
||||
|
|
@ -10,6 +11,7 @@ import { hash } from "../utils/hash";
|
|||
import type { FileChangeNotifier } from "./file-change-notifier";
|
||||
import { Lock } from "../utils/data-structures/locks";
|
||||
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||
import type { Logger } from "../tracing/logger";
|
||||
|
||||
// Cursor positions are updated separately from documents. However, a given cursor position is only
|
||||
// valid within a certain version of the document it belongs to. This class tracks previous and the latest
|
||||
|
|
@ -22,22 +24,29 @@ export class CursorTracker {
|
|||
(cursors: MaybeOutdatedClientCursors[]) => unknown
|
||||
>();
|
||||
|
||||
private readonly updateLock = new Lock();
|
||||
private readonly updateLock: Lock;
|
||||
|
||||
private knownRemoteCursors: (ClientCursors & {
|
||||
upToDateness: DocumentUpToDateness;
|
||||
})[] = [];
|
||||
|
||||
private lastLocalCursorState: DocumentWithCursors[] = [];
|
||||
private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] =
|
||||
[];
|
||||
// Cache the previously sent state as a JSON string rather than as the
|
||||
// array. We mutate `documentsWithCursors` in-place after the cache check
|
||||
// (setting `vaultUpdateId = null` for dirty docs); storing the array would
|
||||
// alias and the next call's equality check would compare against
|
||||
// post-mutation state.
|
||||
private lastLocalCursorStateJson = "[]";
|
||||
private lastLocalCursorStateWithoutDirtyDocumentsJson = "[]";
|
||||
|
||||
public constructor(
|
||||
private readonly database: Database,
|
||||
logger: Logger,
|
||||
private readonly queue: SyncEventQueue,
|
||||
private readonly webSocketManager: WebSocketManager,
|
||||
private readonly fileOperations: FileOperations,
|
||||
private readonly fileChangeNotifier: FileChangeNotifier
|
||||
) {
|
||||
this.updateLock = new Lock(CursorTracker.name, logger);
|
||||
|
||||
this.webSocketManager.onRemoteCursorsUpdateReceived.add(
|
||||
async (clientCursors) => {
|
||||
await this.updateLock.withLock(async () => {
|
||||
|
|
@ -53,7 +62,7 @@ export class CursorTracker {
|
|||
|
||||
for (const cursor of clientCursors.filter((client) =>
|
||||
client.documentsWithCursors.every(
|
||||
(doc) => doc.vault_update_id != null
|
||||
(doc) => doc.vaultUpdateId != null
|
||||
)
|
||||
)) {
|
||||
updatedKnownRemoteCursors.push({
|
||||
|
|
@ -77,14 +86,20 @@ export class CursorTracker {
|
|||
for (const clientCursor of this.knownRemoteCursors) {
|
||||
if (
|
||||
clientCursor.documentsWithCursors.some(
|
||||
(document) =>
|
||||
document.relative_path === relativePath
|
||||
(document) => document.relativePath === relativePath
|
||||
)
|
||||
) {
|
||||
clientCursor.upToDateness =
|
||||
await this.getDocumentsUpToDateness(clientCursor);
|
||||
}
|
||||
}
|
||||
// Drop the local-cursor send-cache so the next call re-reads
|
||||
// the file. The first cache key is the editor's input, which
|
||||
// doesn't change when the file content does — without this,
|
||||
// a remote update flipping the file from dirty back to clean
|
||||
// would never re-send the cursor with a fresh `vaultUpdateId`.
|
||||
this.lastLocalCursorStateJson = "";
|
||||
this.lastLocalCursorStateWithoutDirtyDocumentsJson = "";
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -95,70 +110,67 @@ export class CursorTracker {
|
|||
public async sendLocalCursorsToServer(
|
||||
documentToCursors: Record<RelativePath, CursorSpan[]>
|
||||
): Promise<void> {
|
||||
const documentsWithCursors: DocumentWithCursors[] = [];
|
||||
// Serialise concurrent senders so they don't interleave on the
|
||||
// disk reads + state mutations and emit out-of-order cursor messages.
|
||||
await this.updateLock.withLock(async () => {
|
||||
const documentsWithCursors: DocumentWithCursors[] = [];
|
||||
|
||||
for (const [relativePath, cursors] of Object.entries(
|
||||
documentToCursors
|
||||
)) {
|
||||
const record =
|
||||
this.database.getLatestDocumentByRelativePath(relativePath);
|
||||
for (const [relativePath, cursors] of Object.entries(
|
||||
documentToCursors
|
||||
)) {
|
||||
const record = this.queue.getRecordByLocalPath(relativePath);
|
||||
|
||||
if (!record) {
|
||||
continue; // Let's wait for the file to be created before sending cursors
|
||||
if (!record) {
|
||||
continue; // Let's wait for the file to be created before sending cursors
|
||||
}
|
||||
|
||||
documentsWithCursors.push({
|
||||
relativePath: relativePath,
|
||||
documentId: record.documentId,
|
||||
vaultUpdateId: record.parentVersionId,
|
||||
cursors: cursors.map(({ start, end }) => ({
|
||||
start: Math.min(start, end),
|
||||
end: Math.max(start, end)
|
||||
})) // the client might send directional selections
|
||||
});
|
||||
}
|
||||
|
||||
if (!record.metadata) {
|
||||
continue; // this is a new document, no need to sync the cursors
|
||||
const beforeJson = JSON.stringify(documentsWithCursors);
|
||||
if (this.lastLocalCursorStateJson === beforeJson) {
|
||||
// Caching step to avoid reading the edited files all the time
|
||||
return;
|
||||
}
|
||||
this.lastLocalCursorStateJson = beforeJson;
|
||||
|
||||
for (const doc of documentsWithCursors) {
|
||||
const readContent = await this.fileOperations.read(
|
||||
doc.relativePath
|
||||
);
|
||||
const record = this.queue.getRecordByLocalPath(
|
||||
doc.relativePath
|
||||
);
|
||||
if (record?.remoteHash !== (await hash(readContent))) {
|
||||
doc.vaultUpdateId = null;
|
||||
}
|
||||
}
|
||||
|
||||
documentsWithCursors.push({
|
||||
relative_path: relativePath,
|
||||
document_id: record.documentId,
|
||||
vault_update_id: record.metadata.parentVersionId,
|
||||
cursors: cursors.map(({ start, end }) => ({
|
||||
start: Math.min(start, end),
|
||||
end: Math.max(start, end)
|
||||
})) // the client might send directional selections
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
JSON.stringify(this.lastLocalCursorState) ===
|
||||
JSON.stringify(documentsWithCursors)
|
||||
) {
|
||||
// Caching step to avoid reading the edited files all the time
|
||||
return;
|
||||
}
|
||||
this.lastLocalCursorState = documentsWithCursors;
|
||||
|
||||
for (const doc of documentsWithCursors) {
|
||||
const readContent = await this.fileOperations.read(
|
||||
doc.relative_path
|
||||
);
|
||||
const record = this.database.getLatestDocumentByRelativePath(
|
||||
doc.relative_path
|
||||
);
|
||||
if (record?.metadata?.hash !== hash(readContent)) {
|
||||
doc.vault_update_id = null;
|
||||
const afterJson = JSON.stringify(documentsWithCursors);
|
||||
if (
|
||||
this.lastLocalCursorStateWithoutDirtyDocumentsJson === afterJson
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) ===
|
||||
JSON.stringify(documentsWithCursors)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.lastLocalCursorStateWithoutDirtyDocumentsJson = afterJson;
|
||||
|
||||
this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors;
|
||||
|
||||
this.webSocketManager.updateLocalCursors({ documentsWithCursors });
|
||||
this.webSocketManager.updateLocalCursors({ documentsWithCursors });
|
||||
});
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.knownRemoteCursors = [];
|
||||
this.lastLocalCursorState = [];
|
||||
this.lastLocalCursorStateWithoutDirtyDocuments = [];
|
||||
this.lastLocalCursorStateJson = "[]";
|
||||
this.lastLocalCursorStateWithoutDirtyDocumentsJson = "[]";
|
||||
this.updateLock.reset();
|
||||
}
|
||||
|
||||
|
|
@ -223,35 +235,28 @@ export class CursorTracker {
|
|||
private async getDocumentUpToDateness(
|
||||
document: DocumentWithCursors
|
||||
): Promise<DocumentUpToDateness> {
|
||||
const record = this.database.getLatestDocumentByRelativePath(
|
||||
document.relative_path
|
||||
);
|
||||
const record = this.queue.getRecordByLocalPath(document.relativePath);
|
||||
|
||||
if (!record) {
|
||||
// the document of the cursor must be from the future
|
||||
return DocumentUpToDateness.Later;
|
||||
}
|
||||
|
||||
if (
|
||||
(record.metadata?.parentVersionId ?? 0) <
|
||||
(document.vault_update_id ?? 0)
|
||||
) {
|
||||
if (record.parentVersionId < (document.vaultUpdateId ?? 0)) {
|
||||
return DocumentUpToDateness.Later;
|
||||
} else if (
|
||||
(document.vault_update_id ?? 0) <
|
||||
(record.metadata?.parentVersionId ?? 0)
|
||||
) {
|
||||
} else if ((document.vaultUpdateId ?? 0) < record.parentVersionId) {
|
||||
// the document of the cursor must be from the past
|
||||
return DocumentUpToDateness.Prior;
|
||||
}
|
||||
|
||||
const currentContent = await this.fileOperations.read(
|
||||
document.relative_path
|
||||
document.relativePath
|
||||
);
|
||||
|
||||
return this.database.getLatestDocumentByRelativePath(
|
||||
document.relative_path
|
||||
)?.metadata?.hash === hash(currentContent)
|
||||
const currentRecord = this.queue.getRecordByLocalPath(
|
||||
document.relativePath
|
||||
);
|
||||
return currentRecord?.remoteHash === (await hash(currentContent))
|
||||
? DocumentUpToDateness.UpToDate
|
||||
: DocumentUpToDateness.Prior;
|
||||
}
|
||||
|
|
|
|||
138
frontend/sync-client/src/sync-operations/expected-fs-events.ts
Normal file
138
frontend/sync-client/src/sync-operations/expected-fs-events.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import type { RelativePath } from "./types";
|
||||
|
||||
/**
|
||||
* Counter-based registry of filesystem events the syncer is about to
|
||||
* cause. The syncer's own writes/renames/deletes go through
|
||||
* `FileOperations`, which calls into the host filesystem; the host then
|
||||
* fires watcher events that come back through `SyncClient.syncLocallyXxx`.
|
||||
* Without filtering, those echo events would be re-uploaded to the server
|
||||
* and broadcast back, producing an unbounded loop.
|
||||
*
|
||||
* The fix: every fs call in `FileOperations` registers the event it is
|
||||
* about to provoke; the matching `syncLocallyXxx` handler consumes it.
|
||||
* User-initiated edits never register, so they pass through unchanged.
|
||||
*
|
||||
* Counts are per (kind, path) so back-to-back syncer ops on the same path
|
||||
* (e.g. apply remote update then re-apply during convergence) match
|
||||
* one-for-one. If the watcher never fires for a registered op (e.g. the
|
||||
* fs throws before notifying), the entry is left behind; `clear()` is
|
||||
* called on pause/destroy to drop those before they collide with a real
|
||||
* user event later.
|
||||
*/
|
||||
export class ExpectedFsEvents {
|
||||
private readonly creates = new Map<RelativePath, number>();
|
||||
private readonly updates = new Map<RelativePath, number>();
|
||||
private readonly deletes = new Map<RelativePath, number>();
|
||||
// Renames are keyed by `JSON.stringify({oldPath, newPath})` so the
|
||||
// delimiter cannot occur inside either path.
|
||||
private readonly renames = new Map<RelativePath, number>();
|
||||
|
||||
private static renameKey(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): string {
|
||||
return JSON.stringify({ oldPath, newPath });
|
||||
}
|
||||
|
||||
public expectCreate(path: RelativePath): void {
|
||||
this.bump(this.creates, path);
|
||||
}
|
||||
|
||||
public expectUpdate(path: RelativePath): void {
|
||||
this.bump(this.updates, path);
|
||||
}
|
||||
|
||||
public expectDelete(path: RelativePath): void {
|
||||
this.bump(this.deletes, path);
|
||||
}
|
||||
|
||||
public expectRename(oldPath: RelativePath, newPath: RelativePath): void {
|
||||
this.bump(this.renames, ExpectedFsEvents.renameKey(oldPath, newPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a previously-registered expectation when the fs op that registered
|
||||
* it failed before any watcher event could fire. Without this, a leaked
|
||||
* expectation silently swallows the next genuine user event at the same
|
||||
* path (or, for renames, the same `oldPath → newPath` pair).
|
||||
*
|
||||
* Floored at zero: if the watcher *did* fire (op partially completed) and
|
||||
* already consumed the entry, the unexpect is a no-op. The fallback is
|
||||
* acceptable — at worst we re-upload a real edit we'd otherwise filter.
|
||||
*/
|
||||
public unexpectCreate(path: RelativePath): void {
|
||||
this.decrement(this.creates, path);
|
||||
}
|
||||
|
||||
public unexpectUpdate(path: RelativePath): void {
|
||||
this.decrement(this.updates, path);
|
||||
}
|
||||
|
||||
public unexpectDelete(path: RelativePath): void {
|
||||
this.decrement(this.deletes, path);
|
||||
}
|
||||
|
||||
public unexpectRename(oldPath: RelativePath, newPath: RelativePath): void {
|
||||
this.decrement(
|
||||
this.renames,
|
||||
ExpectedFsEvents.renameKey(oldPath, newPath)
|
||||
);
|
||||
}
|
||||
|
||||
public matchCreate(path: RelativePath): boolean {
|
||||
return this.consume(this.creates, path);
|
||||
}
|
||||
|
||||
public matchUpdate(
|
||||
path: RelativePath,
|
||||
oldPath: RelativePath | undefined
|
||||
): boolean {
|
||||
if (oldPath !== undefined) {
|
||||
return this.consume(
|
||||
this.renames,
|
||||
ExpectedFsEvents.renameKey(oldPath, path)
|
||||
);
|
||||
}
|
||||
return this.consume(this.updates, path);
|
||||
}
|
||||
|
||||
public matchDelete(path: RelativePath): boolean {
|
||||
return this.consume(this.deletes, path);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.creates.clear();
|
||||
this.updates.clear();
|
||||
this.deletes.clear();
|
||||
this.renames.clear();
|
||||
}
|
||||
|
||||
private bump(map: Map<RelativePath, number>, key: RelativePath): void {
|
||||
map.set(key, (map.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
private consume(
|
||||
map: Map<RelativePath, number>,
|
||||
key: RelativePath
|
||||
): boolean {
|
||||
const count = map.get(key) ?? 0;
|
||||
if (count === 0) {
|
||||
return false;
|
||||
}
|
||||
if (count === 1) {
|
||||
map.delete(key);
|
||||
} else {
|
||||
map.set(key, count - 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private decrement(map: Map<RelativePath, number>, key: RelativePath): void {
|
||||
const count = map.get(key) ?? 0;
|
||||
if (count <= 1) {
|
||||
map.delete(key);
|
||||
} else {
|
||||
map.set(key, count - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { RelativePath } from "../persistence/database";
|
||||
import type { RelativePath } from "./types";
|
||||
import { EventListeners } from "../utils/data-structures/event-listeners";
|
||||
|
||||
export class FileChangeNotifier {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { Logger } from "../tracing/logger";
|
||||
import { Settings } from "../persistence/settings";
|
||||
import { STORED_STATE_SCHEMA_VERSION, SyncEventQueue } from "./sync-event-queue";
|
||||
import { scheduleOfflineChanges } from "./offline-change-detector";
|
||||
import type { FileOperations } from "../file-operations/file-operations";
|
||||
import type { RelativePath } from "./types";
|
||||
|
||||
const makeQueue = async (): Promise<SyncEventQueue> => {
|
||||
const logger = new Logger();
|
||||
const settings = new Settings(logger, {}, async () => {
|
||||
/* no-op */
|
||||
});
|
||||
return new SyncEventQueue(
|
||||
settings,
|
||||
logger,
|
||||
{ schemaVersion: STORED_STATE_SCHEMA_VERSION },
|
||||
async () => {
|
||||
/* no-op */
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const makeOperations = (
|
||||
files: Record<string, Uint8Array>
|
||||
): FileOperations => {
|
||||
return {
|
||||
listFilesRecursively: async () => Object.keys(files),
|
||||
read: async (path: RelativePath) => {
|
||||
const data = files[path];
|
||||
if (data === undefined) {
|
||||
throw new Error(`File not found: ${path}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
} as unknown as FileOperations;
|
||||
};
|
||||
|
||||
describe("scheduleOfflineChanges", () => {
|
||||
it("does not bind a local file to a placement-pending record whose remoteRelativePath was persisted before the doc moved on the server", async () => {
|
||||
// The bug: persisted byDocId can carry a placement-pending record
|
||||
// whose `remoteRelativePath` was saved before the doc was moved
|
||||
// server-side. After restart, offline-scan running before WS
|
||||
// catch-up would bind an unrelated local file at that stale path
|
||||
// to the moved doc and push the user's content as an update —
|
||||
// silently corrupting the moved doc and stranding the local file.
|
||||
const queue = await makeQueue();
|
||||
|
||||
// Stale placement-pending record: server has moved this doc
|
||||
// away from "stale-X.md" since this snapshot was saved.
|
||||
await queue.upsertRecord({
|
||||
documentId: "MOVED-DOC",
|
||||
parentVersionId: 5,
|
||||
remoteRelativePath: "stale-X.md" as RelativePath,
|
||||
remoteHash: "hash-from-old-state",
|
||||
localPath: undefined
|
||||
});
|
||||
|
||||
// User has an unrelated local file at the stale path.
|
||||
const operations = makeOperations({
|
||||
"stale-X.md": new TextEncoder().encode(
|
||||
"user's unrelated local content"
|
||||
)
|
||||
});
|
||||
|
||||
const enqueued: { kind: string; path: string }[] = [];
|
||||
await scheduleOfflineChanges(
|
||||
new Logger(),
|
||||
operations,
|
||||
queue,
|
||||
(path) => enqueued.push({ kind: "create", path }),
|
||||
(args) => enqueued.push({ kind: "update", path: args.relativePath }),
|
||||
(path) => enqueued.push({ kind: "delete", path })
|
||||
);
|
||||
|
||||
// The local file must become a fresh CREATE — never a hostile
|
||||
// UPDATE on the moved doc.
|
||||
assert.deepStrictEqual(enqueued, [
|
||||
{ kind: "create", path: "stale-X.md" }
|
||||
]);
|
||||
|
||||
// The placement-pending record must remain placement-pending —
|
||||
// its localPath must not have been bound to the unrelated user
|
||||
// file. The reconciler will place it correctly once WS catch-up
|
||||
// updates `remoteRelativePath` to the doc's current location.
|
||||
const record = queue.getDocumentByDocumentId("MOVED-DOC");
|
||||
assert.notStrictEqual(record, undefined);
|
||||
assert.strictEqual(record?.localPath, undefined);
|
||||
});
|
||||
|
||||
it("schedules an update for a local file that matches a settled record's localPath", async () => {
|
||||
const queue = await makeQueue();
|
||||
await queue.upsertRecord({
|
||||
documentId: "SETTLED-DOC",
|
||||
parentVersionId: 2,
|
||||
remoteRelativePath: "doc.md" as RelativePath,
|
||||
remoteHash: "hash",
|
||||
localPath: "doc.md" as RelativePath
|
||||
});
|
||||
|
||||
const operations = makeOperations({
|
||||
"doc.md": new TextEncoder().encode("content")
|
||||
});
|
||||
|
||||
const enqueued: { kind: string; path: string }[] = [];
|
||||
await scheduleOfflineChanges(
|
||||
new Logger(),
|
||||
operations,
|
||||
queue,
|
||||
(path) => enqueued.push({ kind: "create", path }),
|
||||
(args) => enqueued.push({ kind: "update", path: args.relativePath }),
|
||||
(path) => enqueued.push({ kind: "delete", path })
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(enqueued, [
|
||||
{ kind: "update", path: "doc.md" }
|
||||
]);
|
||||
});
|
||||
|
||||
it("schedules a delete for a settled record whose local file is missing", async () => {
|
||||
const queue = await makeQueue();
|
||||
await queue.upsertRecord({
|
||||
documentId: "VANISHED-DOC",
|
||||
parentVersionId: 4,
|
||||
remoteRelativePath: "gone.md" as RelativePath,
|
||||
remoteHash: "hash",
|
||||
localPath: "gone.md" as RelativePath
|
||||
});
|
||||
|
||||
const operations = makeOperations({});
|
||||
|
||||
const enqueued: { kind: string; path: string }[] = [];
|
||||
await scheduleOfflineChanges(
|
||||
new Logger(),
|
||||
operations,
|
||||
queue,
|
||||
(path) => enqueued.push({ kind: "create", path }),
|
||||
(args) => enqueued.push({ kind: "update", path: args.relativePath }),
|
||||
(path) => enqueued.push({ kind: "delete", path })
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(enqueued, [
|
||||
{ kind: "delete", path: "gone.md" }
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects an offline rename when an untracked file matches a deleted record's content hash", async () => {
|
||||
const queue = await makeQueue();
|
||||
const content = new TextEncoder().encode("body");
|
||||
const contentHash = await (await import("../utils/hash")).hash(content);
|
||||
|
||||
await queue.upsertRecord({
|
||||
documentId: "DOC-1",
|
||||
parentVersionId: 5,
|
||||
remoteRelativePath: "old.md" as RelativePath,
|
||||
remoteHash: contentHash,
|
||||
localPath: "old.md" as RelativePath
|
||||
});
|
||||
const operations = makeOperations({ "new.md": content });
|
||||
|
||||
const enqueued: {
|
||||
kind: string;
|
||||
path: string;
|
||||
oldPath?: string;
|
||||
}[] = [];
|
||||
await scheduleOfflineChanges(
|
||||
new Logger(),
|
||||
operations,
|
||||
queue,
|
||||
(path) => enqueued.push({ kind: "create", path }),
|
||||
(args) =>
|
||||
enqueued.push({
|
||||
kind: "update",
|
||||
path: args.relativePath,
|
||||
oldPath: args.oldPath
|
||||
}),
|
||||
(path) => enqueued.push({ kind: "delete", path })
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(enqueued, [
|
||||
{ kind: "update", path: "new.md", oldPath: "old.md" }
|
||||
]);
|
||||
});
|
||||
});
|
||||
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