Compare commits

...

26 commits

Author SHA1 Message Date
3ba0b7a88b wip again
Some checks failed
Check / build (pull_request) Failing after 9s
E2E tests / build (pull_request) Failing after 9s
Publish CLI / publish-docker (pull_request) Failing after 2m46s
Publish server Docker image / publish-docker (pull_request) Failing after 34m36s
2026-04-08 08:06:30 +01:00
53bfbfaa4a .. 2026-04-07 21:28:52 +01:00
5a4723cd00 renames 2026-04-07 21:03:21 +01:00
d5958fcbaa . 2026-04-06 22:01:10 +01:00
1a4e39d57a wip better queue 2026-04-06 21:55:21 +01:00
d034ad5cb3 WIP 2026-04-06 13:01:47 +01:00
0e3e5a99cd Update tests 2026-04-06 13:01:34 +01:00
64ca5a82ef Fix lints 2026-04-06 11:17:18 +01:00
3784418567 Fix testing setup 2026-04-01 23:13:46 +01:00
0897f7a545 Make hash async 2026-04-01 22:29:57 +01:00
22dfdc069b Update imports 2026-04-01 21:58:57 +01:00
7c203bc5c9 Fix mock client event triggering 2026-04-01 21:57:42 +01:00
19e4c39f44 Parallel clean up 2026-04-01 21:46:29 +01:00
03b5c223d6 Reconcile outside of async 2026-04-01 21:46:00 +01:00
1bb1ca99dd Delete shouldn't move 2026-04-01 21:45:45 +01:00
4aeec1b021 Use tempfs 2026-04-01 21:38:57 +01:00
adad2d5703 No more create promis 2026-03-28 18:16:22 +00:00
44947dc3a5 Add vault listing endpoint 2026-03-28 18:15:43 +00:00
9ae1a5e09e Add sync event queue 2026-03-28 17:24:45 +00:00
f3d985cc57 Remove duplicate errors 2026-03-28 12:07:57 +00:00
1c6cd80b64 Update hashing 2026-03-28 12:07:44 +00:00
65d75dec40 Copy types 2026-03-28 12:07:14 +00:00
48234de10d Update types 2026-03-28 11:57:50 +00:00
4493365076 Simplify syncing logic 2026-03-28 11:55:37 +00:00
e8c57b3a37 Extend E2E assertions 2026-03-28 11:46:06 +00:00
904a2737d4 Bump deps, improve e2e test and pick up changes in the plugin 2026-03-28 11:17:18 +00:00
154 changed files with 9976 additions and 6034 deletions

9
.gitignore vendored
View file

@ -7,15 +7,18 @@ node_modules
# Frontend build folders # Frontend build folders
frontend/*/dist frontend/*/dist
sync-server/db.sqlite3*
sync-server/databases
# Rust build folders # Rust build folders
sync-server/target sync-server/target
sync-server/artifacts sync-server/artifacts
sync-server/bindings/*.ts sync-server/bindings/*.ts
# build folders
sync-server/db.sqlite3*
**/databases
*.log *.log
*.sqlx *.sqlx
target target
.task

110
CLAUDE.md
View file

@ -1,110 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
VaultLink is a self-hosted Obsidian plugin for real-time collaborative file syncing. The project consists of a Rust-based sync server and a TypeScript frontend with three main components: an Obsidian plugin, a sync client library, and a test client.
## Architecture
### Core Components
- **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
### Key Technologies
- **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
## Development Commands
### 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
```
### Frontend Development
```bash
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
```
### Database Setup (Development)
```bash
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
```
### Scripts
- `scripts/check.sh`: Full CI check (builds, lints, tests both server and frontend)
- `scripts/check.sh --fix`: Same as above but auto-fixes linting and formatting issues
- `scripts/e2e.sh`: End-to-end testing
- `scripts/clean-up.sh`: Clean logs and database files
- `scripts/bump-version.sh patch`: Publish new version
- `scripts/update-api-types.sh`: Update TypeScript bindings from Rust types
## Code Structure
### 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
### Type Generation
Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/bindings/` and used by frontend packages.
### 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
## Testing
### Running Tests
- Server: `cargo test --verbose`
- Frontend: `npm run test` (runs Jest across all workspaces)
- E2E: `scripts/e2e.sh`
### Test Structure
- Rust: Unit tests alongside source files
- TypeScript: `.test.ts` files using Jest
- E2E: Uses test-client to simulate multiple concurrent users
## Code Style
### Rust
- Uses extensive Clippy lints (see Cargo.toml)
- Follows pedantic linting rules
- Forbids unsafe code
- Uses cargo fmt with default settings
### TypeScript
- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings
- ESLint with unused imports plugin
- Consistent across all three frontend packages

View file

@ -8,12 +8,12 @@
## Develop ## Develop
### Install [nvm](https://github.com/nvm-sh/nvm) ### Set up Node.JS 25 with [nvm](https://github.com/nvm-sh/nvm)
- `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash` - `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash`
- `nvm install 22` - `nvm install 25`
- `nvm use 22` - `nvm use 25`
- Optionally set the system-wide default: `nvm alias default 22` - Optionally, set the system-wide default: `nvm alias default 25`
### Set up Rust ### Set up Rust

View file

@ -24,6 +24,9 @@ Clients always start with syncing disabled.
- `barrier` — retry until all clients converge to identical file state (60s timeout) - `barrier` — retry until all clients converge to identical file state (60s timeout)
- `enable-sync` / `disable-sync` — simulate going online/offline - `enable-sync` / `disable-sync` — simulate going online/offline
**WebSocket control** (per-client):
- `pause-websocket` / `resume-websocket` — buffer/release WebSocket messages for a specific client
**Server control:** **Server control:**
- `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process - `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process

View file

@ -6,7 +6,7 @@ export const STOP_TIMEOUT_MS = 5_000;
export const CONVERGENCE_TIMEOUT_MS = 60_000; export const CONVERGENCE_TIMEOUT_MS = 60_000;
export const CONVERGENCE_RETRY_DELAY_MS = 500; export const CONVERGENCE_RETRY_DELAY_MS = 500;
export const AGENT_INIT_TIMEOUT_MS = 30_000; export const AGENT_INIT_TIMEOUT_MS = 30_000;
export const IS_SYNC_ENABLED_DEFAULT = false; export const IS_SYNC_ENABLED_BY_DEFAULT = false;
export const WAIT_TIMEOUT_MS = 60_000; export const WAIT_TIMEOUT_MS = 60_000;
export const WEBSOCKET_CONNECT_TIMEOUT_MS = 10_000; export const WEBSOCKET_CONNECT_TIMEOUT_MS = 10_000;

View file

@ -3,7 +3,8 @@ import { SyncClient, debugging, LogLevel } from "sync-client";
import { assert } from "./utils/assert"; import { assert } from "./utils/assert";
import { sleep } from "./utils/sleep"; import { sleep } from "./utils/sleep";
import { withTimeout } from "./utils/with-timeout"; import { withTimeout } from "./utils/with-timeout";
import { IS_SYNC_ENABLED_DEFAULT, WAIT_TIMEOUT_MS, WEBSOCKET_CONNECT_TIMEOUT_MS, WEBSOCKET_POLL_INTERVAL_MS } from "./consts"; import { IS_SYNC_ENABLED_BY_DEFAULT, WAIT_TIMEOUT_MS, WEBSOCKET_CONNECT_TIMEOUT_MS, WEBSOCKET_POLL_INTERVAL_MS } from "./consts";
import { ManagedWebSocketFactory } from "./managed-websocket";
@ -15,9 +16,10 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
settings: Partial<SyncSettings>; settings: Partial<SyncSettings>;
database: Partial<StoredDatabase>; database: Partial<StoredDatabase>;
}> = {}; }> = {};
private isSyncEnabled = IS_SYNC_ENABLED_DEFAULT; private isSyncEnabled = IS_SYNC_ENABLED_BY_DEFAULT;
private readonly syncErrors: Error[] = []; private readonly syncErrors: Error[] = [];
private readonly pendingSyncOperations = new Set<Promise<void>>(); private readonly pendingSyncOperations = new Set<Promise<void>>();
private readonly wsFactory = new ManagedWebSocketFactory();
public constructor( public constructor(
clientId: number, clientId: number,
@ -32,7 +34,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
public async init( public async init(
fetchImplementation: typeof globalThis.fetch, fetchImplementation: typeof globalThis.fetch,
webSocketImplementation: typeof globalThis.WebSocket
): Promise<void> { ): Promise<void> {
this.client = await SyncClient.create({ this.client = await SyncClient.create({
fs: this, fs: this,
@ -41,7 +42,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
save: async (data) => void (this.data = data) save: async (data) => void (this.data = data)
}, },
fetch: fetchImplementation, fetch: fetchImplementation,
webSocket: webSocketImplementation webSocket: this.wsFactory.constructorFn
}); });
this.client.logger.onLogEmitted.add((line) => { this.client.logger.onLogEmitted.add((line) => {
@ -75,68 +76,14 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
} }
} }
public async createFile(path: string, content: string): Promise<void> { public pauseWebSocket(): void {
this.log(`Creating file ${path} with content: ${content}`); this.log("Pausing WebSocket message delivery");
if (this.files.has(path)) { this.wsFactory.pause();
throw new Error(`File ${path} already exists`);
}
const contentBytes = new TextEncoder().encode(content);
this.files.set(path, contentBytes);
if (this.isSyncEnabled) {
this.enqueueSync(async () =>
this.client.syncLocallyCreatedFile(path)
);
}
} }
public async updateFile(path: string, content: string): Promise<void> { public resumeWebSocket(): void {
this.log(`Updating file ${path} with content: ${content}`); this.log("Resuming WebSocket message delivery");
if (!this.files.has(path)) { this.wsFactory.resume();
throw new Error(
`File ${path} does not exist on client ${this.clientId}`
);
}
const contentBytes = new TextEncoder().encode(content);
this.files.set(path, contentBytes);
if (this.isSyncEnabled) {
this.enqueueSync(async () =>
this.client.syncLocallyUpdatedFile({ relativePath: path })
);
}
}
public async renameFile(oldPath: string, newPath: string): Promise<void> {
this.log(`Renaming file ${oldPath} to ${newPath}`);
const file = this.files.get(oldPath);
if (!file) {
throw new Error(
`File ${oldPath} does not exist on client ${this.clientId}`
);
}
this.files.set(newPath, file);
if (oldPath !== newPath) {
this.files.delete(oldPath);
}
if (this.isSyncEnabled) {
this.enqueueSync(async () =>
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
})
);
}
}
public async deleteFile(path: string): Promise<void> {
this.log(`Deleting file ${path}`);
this.files.delete(path);
if (this.isSyncEnabled) {
this.enqueueSync(async () =>
this.client.syncLocallyDeletedFile(path)
);
}
} }
public async waitForSync(): Promise<void> { public async waitForSync(): Promise<void> {
@ -191,9 +138,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
await this.waitForWebSocket(); await this.waitForWebSocket();
} }
public async getFiles(): Promise<RelativePath[]> {
return this.listFilesRecursively();
}
public async getFileContent(path: string): Promise<string> { public async getFileContent(path: string): Promise<string> {
const bytes = await this.read(path); const bytes = await this.read(path);
@ -226,10 +170,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
this.log("Cleanup complete"); this.log("Cleanup complete");
} }
// Yield the event loop before each FS operation so that the SyncClient's
// async calls create real interleaving points, matching the behavior of
// actual disk I/O. Without this, all FS operations resolve in the same
// microtask, hiding concurrency bugs that only manifest with real latency.
public override async read(path: RelativePath): Promise<Uint8Array> { public override async read(path: RelativePath): Promise<Uint8Array> {
await Promise.resolve(); await Promise.resolve();
return super.read(path); return super.read(path);
@ -240,33 +180,50 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem {
content: Uint8Array content: Uint8Array
): Promise<void> { ): Promise<void> {
await Promise.resolve(); await Promise.resolve();
return super.write(path, content); const isNew = !this.files.has(path);
await super.write(path, content);
if (isNew) {
this.enqueueSync(async () => { this.client.syncLocallyCreatedFile(path); }
);
} else {
this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); }
);
}
} }
public override async atomicUpdateText( public override async atomicUpdateText(
path: RelativePath, path: RelativePath,
updater: (current: TextWithCursors) => TextWithCursors updater: (current: TextWithCursors) => TextWithCursors
): Promise<string> { ): Promise<string> {
await Promise.resolve(); const result = await super.atomicUpdateText(path, updater);
return super.atomicUpdateText(path, updater); this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); }
);
return result;
} }
public override async exists(path: RelativePath): Promise<boolean> {
await Promise.resolve();
return super.exists(path);
}
public override async delete(path: RelativePath): Promise<void> { public override async delete(path: RelativePath): Promise<void> {
await Promise.resolve(); await super.delete(path);
return super.delete(path); if (this.isSyncEnabled) {
this.enqueueSync(async () => { this.client.syncLocallyDeletedFile(path); }
);
}
} }
public override async rename( public override async rename(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath newPath: RelativePath
): Promise<void> { ): Promise<void> {
await Promise.resolve(); await super.rename(oldPath, newPath);
return super.rename(oldPath, newPath); this.enqueueSync(async () => {
this.client.syncLocallyUpdatedFile({
oldPath,
relativePath: newPath
});
}
);
} }
private async waitForWebSocket(): Promise<void> { private async waitForWebSocket(): Promise<void> {

View file

@ -0,0 +1,170 @@
/**
* A WebSocket wrapper that can pause and resume message delivery.
* When paused, incoming messages are buffered. When resumed, buffered
* messages are delivered in order via the onmessage handler.
*/
export class ManagedWebSocket implements WebSocket {
private readonly ws: WebSocket;
private paused = false;
private readonly bufferedMessages: MessageEvent[] = [];
private externalOnMessage: ((event: MessageEvent) => unknown) | null = null;
public constructor(url: string | URL, protocols?: string | string[]) {
this.ws = new WebSocket(url, protocols);
this.ws.onmessage = (event: MessageEvent): void => {
if (this.paused) {
this.bufferedMessages.push(event);
} else {
this.externalOnMessage?.(event);
}
};
}
public pause(): void {
this.paused = true;
}
public resume(): void {
this.paused = false;
const messages = this.bufferedMessages.splice(0);
for (const msg of messages) {
this.externalOnMessage?.(msg);
}
}
get readyState(): number {
return this.ws.readyState;
}
get url(): string {
return this.ws.url;
}
get protocol(): string {
return this.ws.protocol;
}
get extensions(): string {
return this.ws.extensions;
}
get bufferedAmount(): number {
return this.ws.bufferedAmount;
}
get binaryType(): BinaryType {
return this.ws.binaryType;
}
set binaryType(value: BinaryType) {
this.ws.binaryType = value;
}
get onopen(): ((this: WebSocket, ev: Event) => unknown) | null {
return this.ws.onopen;
}
set onopen(handler: ((this: WebSocket, ev: Event) => unknown) | null) {
this.ws.onopen = handler;
}
get onclose(): ((this: WebSocket, ev: CloseEvent) => unknown) | null {
return this.ws.onclose;
}
set onclose(handler: ((this: WebSocket, ev: CloseEvent) => unknown) | null) {
this.ws.onclose = handler;
}
get onerror(): ((this: WebSocket, ev: Event) => unknown) | null {
return this.ws.onerror;
}
set onerror(handler: ((this: WebSocket, ev: Event) => unknown) | null) {
this.ws.onerror = handler;
}
get onmessage(): ((this: WebSocket, ev: MessageEvent) => unknown) | null {
return this.externalOnMessage;
}
set onmessage(
handler: ((this: WebSocket, ev: MessageEvent) => unknown) | null
) {
this.externalOnMessage = handler;
}
public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
this.ws.send(data);
}
public close(code?: number, reason?: string): void {
this.ws.close(code, reason);
}
public addEventListener(
...args: Parameters<WebSocket["addEventListener"]>
): void {
this.ws.addEventListener(...args);
}
public removeEventListener(
...args: Parameters<WebSocket["removeEventListener"]>
): void {
this.ws.removeEventListener(...args);
}
public dispatchEvent(event: Event): boolean {
return this.ws.dispatchEvent(event);
}
static readonly CONNECTING = WebSocket.CONNECTING;
static readonly OPEN = WebSocket.OPEN;
static readonly CLOSING = WebSocket.CLOSING;
static readonly CLOSED = WebSocket.CLOSED;
readonly CONNECTING = WebSocket.CONNECTING;
readonly OPEN = WebSocket.OPEN;
readonly CLOSING = WebSocket.CLOSING;
readonly CLOSED = WebSocket.CLOSED;
}
/**
* Factory that creates ManagedWebSocket instances and tracks them
* for pause/resume control from the test harness
*/
export class ManagedWebSocketFactory {
private readonly instances: ManagedWebSocket[] = [];
public get constructorFn(): typeof globalThis.WebSocket {
const factory = this;
const ctor = function ManagedWS(
url: string | URL,
protocols?: string | string[]
): ManagedWebSocket {
const ws = new ManagedWebSocket(url, protocols);
factory.instances.push(ws);
return ws;
} as unknown as typeof globalThis.WebSocket;
Object.defineProperty(ctor, "CONNECTING", { value: WebSocket.CONNECTING });
Object.defineProperty(ctor, "OPEN", { value: WebSocket.OPEN });
Object.defineProperty(ctor, "CLOSING", { value: WebSocket.CLOSING });
Object.defineProperty(ctor, "CLOSED", { value: WebSocket.CLOSED });
return ctor;
}
public pause(): void {
for (const ws of this.instances) {
ws.pause();
}
}
public resume(): void {
for (const ws of this.instances) {
ws.resume();
}
}
}

View file

@ -40,8 +40,10 @@ export class ServerControl {
const reservation = await findFreePort(); const reservation = await findFreePort();
this._port = reservation.port; this._port = reservation.port;
// Prefer tmpfs (/host/tmp) over disk-backed /tmp for faster SQLite I/O
const tmpBase = fs.existsSync("/host/tmp") ? "/host/tmp" : os.tmpdir();
this.tempDir = fs.mkdtempSync( this.tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), "vault-link-test-") path.join(tmpBase, "vault-link-test-")
); );
const tempConfigPath = path.join(this.tempDir, "config.yml"); const tempConfigPath = path.join(this.tempDir, "config.yml");
const dbDir = path.join(this.tempDir, "databases"); const dbDir = path.join(this.tempDir, "databases");

View file

@ -16,7 +16,9 @@ export type TestStep =
| { type: "pause-server" } | { type: "pause-server" }
| { type: "resume-server" } | { type: "resume-server" }
| { type: "barrier" } | { type: "barrier" }
| { type: "assert-consistent"; verify?: (state: AssertableState) => void }; | { type: "assert-consistent"; verify?: (state: AssertableState) => void }
| { type: "pause-websocket"; client: number }
| { type: "resume-websocket"; client: number };
export interface TestDefinition { export interface TestDefinition {
description?: string; description?: string;

View file

@ -49,7 +49,7 @@ import { offlineMoveThenRemoteDeleteTest } from "./tests/offline-move-then-remot
import { resetClearsRecentlyDeletedResurrectionTest } from "./tests/reset-clears-recently-deleted-resurrection.test"; import { resetClearsRecentlyDeletedResurrectionTest } from "./tests/reset-clears-recently-deleted-resurrection.test";
import { moveThenDeleteStalePathTest } from "./tests/move-then-delete-stale-path.test"; import { moveThenDeleteStalePathTest } from "./tests/move-then-delete-stale-path.test";
import { interruptedDeleteRetryTest } from "./tests/interrupted-delete-retry.test"; import { interruptedDeleteRetryTest } from "./tests/interrupted-delete-retry.test";
import { updateSurvivesRemoteDeleteTest } from "./tests/update-survives-remote-delete.test"; import { updateDoesNotSurvivesRemoteDeleteTest } from "./tests/update-survives-remote-delete.test";
import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test"; import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test";
import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test"; import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test";
import { migrateKeyPreservesExistingTest } from "./tests/migrate-key-preserves-existing.test"; import { migrateKeyPreservesExistingTest } from "./tests/migrate-key-preserves-existing.test";
@ -65,6 +65,33 @@ import { createRenameResponseSkipsFileTest } from "./tests/create-rename-respons
import { onlineCreateRenameConcurrentCreateOrphanTest } from "./tests/online-create-rename-concurrent-create-orphan.test"; import { onlineCreateRenameConcurrentCreateOrphanTest } from "./tests/online-create-rename-concurrent-create-orphan.test";
import { concurrentRenameFirstWinsTest } from "./tests/concurrent-rename-first-wins.test"; import { concurrentRenameFirstWinsTest } from "./tests/concurrent-rename-first-wins.test";
import { binaryToTextTransitionTest } from "./tests/binary-to-text-transition.test"; import { binaryToTextTransitionTest } from "./tests/binary-to-text-transition.test";
import { textPendingCreateNotDisplacedTest } from "./tests/1-text-pending-create-not-displaced.test";
import { binaryPendingCreateNotDisplacedTest } from "./tests/2-binary-pending-create-not-displaced.test";
import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/3-coalesce-update-remote-update-data-loss.test";
import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/4-coalesced-remote-update-watermark-loss.test";
import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/5-concurrent-delete-during-remote-update.test";
import { concurrentEditExactSamePositionTest } from "./tests/6-concurrent-edit-exact-same-position.test";
import { concurrentRenameAndCreateAtTargetTest as concurrentRenameAndCreateAtTargetRenameFirstTest } from "./tests/7-concurrent-rename-and-create-at-target.test";
import { concurrentRenameAndCreateAtTargetTest as concurrentRenameAndCreateAtTargetCreateFirstTest } from "./tests/8-concurrent-rename-and-create-at-target.test";
import { concurrentRenameSameTargetTest } from "./tests/9-concurrent-rename-same-target.test";
import { concurrentUpdateDiffConsistencyTest } from "./tests/10-concurrent-update-diff-consistency.test";
import { userParenthesizedFileNotDeletedTest } from "./tests/10-user-parenthesized-file-not-deleted.test";
import { createDeleteNoopTest } from "./tests/11-create-delete-noop.test";
import { createMergeDeleteTest } from "./tests/12-create-merge-delete.test";
import { moveIdenticalContentAmbiguityTest } from "./tests/13-move-identical-content-ambiguity.test";
import { createUpdateCoalesceServerPauseTest } from "./tests/15-create-update-coalesce-server-pause.test";
import { createDuringReconciliationTest } from "./tests/16-create-during-reconciliation.test";
import { createMergePreservesRenamedUpdateTest } from "./tests/17-create-merge-preserves-renamed-update.test";
import { createRenameCreateSamePathTest } from "./tests/18-create-rename-create-same-path.test";
import { moveChainThreeFilesTest } from "./tests/19-move-chain-three-files.test";
import { deleteByOtherClientThenRecreateTest } from "./tests/delete-by-other-client-then-recreate.test";
import { onlineDeleteRecreateRapidCycleTest } from "./tests/online-delete-recreate-rapid-cycle.test";
import { onlineEditVsDeleteConvergenceTest } from "./tests/online-edit-vs-delete-convergence.test";
import { rapidEditDeleteOnlineConvergenceTest } from "./tests/rapid-edit-delete-online-convergence.test";
import { serverPauseDeleteRecreateTest } from "./tests/server-pause-delete-recreate.test";
import { onlineBothCreateSamePathDeconflictTest } from "./tests/online-both-create-same-path-deconflict.test";
import { onlineCreateUpdateWhileOtherCreatesSamePathTest } from "./tests/online-create-update-while-other-creates-same-path.test";
import { displacedFileNotMarkedDeletedTest } from "./tests/displaced-file-not-marked-deleted.test";
export const TESTS: Partial<Record<string, TestDefinition>> = { export const TESTS: Partial<Record<string, TestDefinition>> = {
"rename-create-conflict": renameCreateConflictTest, "rename-create-conflict": renameCreateConflictTest,
@ -117,7 +144,7 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
"move-then-delete-stale-path": moveThenDeleteStalePathTest, "move-then-delete-stale-path": moveThenDeleteStalePathTest,
"offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest, "offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest,
"interrupted-delete-retry": interruptedDeleteRetryTest, "interrupted-delete-retry": interruptedDeleteRetryTest,
"update-survives-remote-delete": updateSurvivesRemoteDeleteTest, "update-survives-remote-delete": updateDoesNotSurvivesRemoteDeleteTest,
"move-preserves-remote-update": movePreservesRemoteUpdateTest, "move-preserves-remote-update": movePreservesRemoteUpdateTest,
"recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest, "recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest,
"migrate-key-preserves-existing": migrateKeyPreservesExistingTest, "migrate-key-preserves-existing": migrateKeyPreservesExistingTest,
@ -133,4 +160,31 @@ export const TESTS: Partial<Record<string, TestDefinition>> = {
"online-create-rename-concurrent-create-orphan": onlineCreateRenameConcurrentCreateOrphanTest, "online-create-rename-concurrent-create-orphan": onlineCreateRenameConcurrentCreateOrphanTest,
"concurrent-rename-first-wins": concurrentRenameFirstWinsTest, "concurrent-rename-first-wins": concurrentRenameFirstWinsTest,
"binary-to-text-transition": binaryToTextTransitionTest, "binary-to-text-transition": binaryToTextTransitionTest,
"text-pending-create-not-displaced": textPendingCreateNotDisplacedTest,
"binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest,
"coalesce-update-remote-update-data-loss": coalesceUpdateRemoteUpdateDataLossTest,
"coalesced-remote-update-watermark-loss": coalescedRemoteUpdateWatermarkLossTest,
"concurrent-delete-during-remote-update": concurrentDeleteDuringRemoteUpdateTest,
"concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest,
"concurrent-rename-and-create-at-target-rename-first": concurrentRenameAndCreateAtTargetRenameFirstTest,
"concurrent-rename-and-create-at-target-create-first": concurrentRenameAndCreateAtTargetCreateFirstTest,
"concurrent-rename-same-target": concurrentRenameSameTargetTest,
"concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest,
"user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest,
"create-delete-noop": createDeleteNoopTest,
"create-merge-delete": createMergeDeleteTest,
"move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest,
"create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest,
"create-during-reconciliation": createDuringReconciliationTest,
"create-merge-preserves-renamed-update": createMergePreservesRenamedUpdateTest,
"create-rename-create-same-path": createRenameCreateSamePathTest,
"move-chain-three-files": moveChainThreeFilesTest,
"delete-by-other-client-then-recreate": deleteByOtherClientThenRecreateTest,
"online-delete-recreate-rapid-cycle": onlineDeleteRecreateRapidCycleTest,
"online-edit-vs-delete-convergence": onlineEditVsDeleteConvergenceTest,
"rapid-edit-delete-online-convergence": rapidEditDeleteOnlineConvergenceTest,
"server-pause-delete-recreate": serverPauseDeleteRecreateTest,
"online-both-create-same-path-deconflict": onlineBothCreateSamePathDeconflictTest,
"online-create-update-while-other-creates-same-path": onlineCreateUpdateWhileOtherCreatesSamePathTest,
"displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest,
}; };

View file

@ -14,7 +14,7 @@ import {
CONVERGENCE_TIMEOUT_MS, CONVERGENCE_TIMEOUT_MS,
CONVERGENCE_RETRY_DELAY_MS, CONVERGENCE_RETRY_DELAY_MS,
AGENT_INIT_TIMEOUT_MS, AGENT_INIT_TIMEOUT_MS,
IS_SYNC_ENABLED_DEFAULT IS_SYNC_ENABLED_BY_DEFAULT
} from "./consts"; } from "./consts";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@ -100,7 +100,7 @@ export class TestRunner {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const settings: Partial<SyncSettings> = { const settings: Partial<SyncSettings> = {
isSyncEnabled: IS_SYNC_ENABLED_DEFAULT, isSyncEnabled: IS_SYNC_ENABLED_BY_DEFAULT,
token: this.token, token: this.token,
vaultName, vaultName,
remoteUri: this.remoteUri remoteUri: this.remoteUri
@ -115,8 +115,6 @@ export class TestRunner {
await withTimeout( await withTimeout(
agent.init( agent.init(
fetch, fetch,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
WebSocket as unknown as typeof globalThis.WebSocket
), ),
AGENT_INIT_TIMEOUT_MS, AGENT_INIT_TIMEOUT_MS,
`Client ${i} init timed out after ${AGENT_INIT_TIMEOUT_MS}ms` `Client ${i} init timed out after ${AGENT_INIT_TIMEOUT_MS}ms`
@ -138,28 +136,22 @@ export class TestRunner {
private async executeStep(step: TestStep): Promise<void> { private async executeStep(step: TestStep): Promise<void> {
switch (step.type) { switch (step.type) {
case "create": case "create":
await this.getAgent(step.client).createFile(
step.path,
step.content
);
break;
case "update": case "update":
await this.getAgent(step.client).updateFile( await this.getAgent(step.client).write(
step.path, step.path,
step.content new TextEncoder().encode(step.content)
); );
break; break;
case "rename": case "rename":
await this.getAgent(step.client).renameFile( await this.getAgent(step.client).rename(
step.oldPath, step.oldPath,
step.newPath step.newPath
); );
break; break;
case "delete": case "delete":
await this.getAgent(step.client).deleteFile(step.path); await this.getAgent(step.client).delete(step.path);
break; break;
case "sync": case "sync":
@ -199,6 +191,14 @@ export class TestRunner {
await this.assertConsistent(step.verify); await this.assertConsistent(step.verify);
break; break;
case "pause-websocket":
this.getAgent(step.client).pauseWebSocket();
break;
case "resume-websocket":
this.getAgent(step.client).resumeWebSocket();
break;
default: { default: {
const unknownStep = step as { type: string }; const unknownStep = step as { type: string };
throw new Error(`Unknown step type: ${unknownStep.type}`); throw new Error(`Unknown step type: ${unknownStep.type}`);
@ -282,7 +282,7 @@ export class TestRunner {
// where background sync could mutate state between reads. // where background sync could mutate state between reads.
const clientFiles: Map<string, string>[] = []; const clientFiles: Map<string, string>[] = [];
for (const agent of this.agents) { for (const agent of this.agents) {
const sortedFiles = (await agent.getFiles()).sort(); const sortedFiles = (await agent.listFilesRecursively()).sort();
const fileMap = new Map<string, string>(); const fileMap = new Map<string, string>();
for (const file of sortedFiles) { for (const file of sortedFiles) {
const content = await agent.getFileContent(file); const content = await agent.getFileContent(file);

View file

@ -10,19 +10,19 @@ export const textPendingCreateNotDisplacedTest: TestDefinition = {
type: "create", type: "create",
client: 0, client: 0,
path: "data.txt", path: "data.txt",
content: "text data from client 0" content: "text data from client-0"
}, },
{ {
type: "create", type: "create",
client: 1, client: 1,
path: "data.txt", path: "data.txt",
content: "text data from client 1" content: "text data from client-1"
}, },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileExists("data.txt").assertAnyFileContains("data from client 0", "data from client 1") } { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileExists("data.txt").assertAnyFileContains("client-0", "client-1") }
] ]
}; };

View file

@ -2,9 +2,10 @@ import type { TestDefinition } from "../test-definition";
export const binaryToTextTransitionTest: TestDefinition = { export const binaryToTextTransitionTest: TestDefinition = {
description: description:
"A .bin file is created and synced. Both clients edit it offline, " + "A .bin file is created and synced. Both clients edit it offline " +
"then it is renamed to .md. Both clients edit different sections " + "(binary last-write-wins), then client 0 renames it to .md and " +
"offline again. The second merge should preserve both edits.", "writes a clean text baseline. Both clients edit different sections " +
"offline. The text merge should preserve both edits.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "data.bin", content: "original content" }, { type: "create", client: 0, path: "data.bin", content: "original content" },
@ -16,32 +17,33 @@ export const binaryToTextTransitionTest: TestDefinition = {
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
{ type: "update", client: 0, path: "data.bin", content: "version A from client 0" }, { type: "update", client: 0, path: "data.bin", content: "version A" },
{ type: "update", client: 1, path: "data.bin", content: "version B from client 1" }, { type: "update", client: 1, path: "data.bin", content: "version B" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContainsAny("data.bin", "version A from client 0", "version B from client 1") }, { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContainsAny("data.bin", "version A", "version B") },
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
{ type: "rename", client: 0, oldPath: "data.bin", newPath: "data.md" }, { type: "rename", client: 0, oldPath: "data.bin", newPath: "data.md" },
{ type: "update", client: 0, path: "data.md", content: "top line\nmiddle line\nbottom line" },
{ type: "sync", client: 0 }, { type: "sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileExists("data.md") }, { type: "assert-consistent", verify: (s) => s.assertContent("data.md", "top line\nmiddle line\nbottom line") },
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 }, { type: "disable-sync", client: 1 },
{ type: "update", client: 0, path: "data.md", content: "top edit from 0\nmiddle line\nshared end" }, { type: "update", client: 0, path: "data.md", content: "alpha\nmiddle line\nbottom line" },
{ type: "update", client: 1, path: "data.md", content: "shared start\nmiddle line\nbottom edit from 1" }, { type: "update", client: 1, path: "data.md", content: "top line\nmiddle line\nbeta" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("data.md", "top edit from 0", "bottom edit from 1") }, { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("data.md", "alpha", "beta") },
], ],
}; };

View file

@ -41,6 +41,6 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
{ type: "sync" }, { type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "brand new content", "edit from client 1") } { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "brand new", "client 1") }
] ]
}; };

View file

@ -0,0 +1,40 @@
import type { TestDefinition } from "../test-definition";
export const displacedFileNotMarkedDeletedTest: TestDefinition = {
description:
"Client 0 creates a new file at path B.md while client 1 renames " +
"A.md to B.md. The remote download of B.md displaces client 1's " +
"renamed file. The displaced document must not be permanently " +
"marked as recently deleted, so it can still be synced.",
clients: 2,
steps: [
{ type: "create", client: 0, path: "A.md", content: "content of A" },
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "disable-sync", client: 1 },
{ type: "create", client: 0, path: "B.md", content: "new file B" },
{ type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" },
{ type: "sync", client: 0 },
{ type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" },
{ type: "update", client: 1, path: "B.md", content: "edited A content" },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileNotExists("A.md")
.assertFileExists("B.md")
.assertContains("B.md", "new file B")
.assertFileExists("C.md")
.assertContains("C.md", "edited A content");
}
}
]
};

View file

@ -2,26 +2,18 @@ import type { TestDefinition } from "../test-definition";
export const localEditLostDuringCreateMergeTest: TestDefinition = { export const localEditLostDuringCreateMergeTest: TestDefinition = {
description: description:
"Client 1 creates doc.md. Client 0 creates the same file offline, then connects with the server paused. " + "Both clients create doc.md with different content while offline. " +
"Client 0 edits the file while the create is stalled. After resume, both clients' content must be merged.", "Client 0 also edits the file before syncing. After both connect, " +
"the merged result should contain content from both clients.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "create", client: 1, path: "doc.md", content: "from-client-1" }, { type: "create", client: 1, path: "doc.md", content: "from-client-1" },
{ type: "sync", client: 1 },
{ {
type: "create", type: "create",
client: 0, client: 0,
path: "doc.md", path: "doc.md",
content: "from-client-0" content: "from-client-0"
}, },
{ type: "pause-server" },
{ type: "enable-sync", client: 0 },
{ {
type: "update", type: "update",
client: 0, client: 0,
@ -29,12 +21,19 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = {
content: "local-edit-during-create" content: "local-edit-during-create"
}, },
{ type: "resume-server" }, { type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "sync" }, { type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("doc.md", "from-client-1", "local-edit-during-create") } {
type: "assert-consistent",
verify: (s) =>
s.assertFileCount(1).assertContains(
"doc.md",
"from-client-1",
"local-edit-during-create"
),
}
] ]
}; };

View file

@ -7,10 +7,8 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
@ -25,17 +23,13 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s) => { verify: (s) => {
s.assertFileNotExists("A.md") s.assertFileNotExists("A.md")
.assertContent("B.md", "content-b"); .assertFileNotExists("A_renamed.md");
s.ifFileExists("A_renamed.md", (s) =>
s.assertContent("A_renamed.md", "content-a")
);
} }
} }
] ]

View file

@ -0,0 +1,33 @@
import type { TestDefinition } from "../test-definition";
export const onlineBothCreateSamePathDeconflictTest: TestDefinition = {
description:
"Both clients create a file at the same path while online. " +
"One client's create gets deconflicted by the server. " +
"Both files must exist on both clients after convergence.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "barrier" },
{ type: "pause-websocket", client: 1 },
{ type: "create", client: 0, path: "A.md", content: " from-client-0 " },
{ type: "update", client: 0, path: "A.md", content: " updated-by-0 " },
{ type: "sync" },
{ type: "create", client: 1, path: "A.md", content: " from-client-1 " },
{ type: "resume-websocket", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent",
verify: (state) => {
state
.assertFileCount(1)
.assertContains("A.md", "updated-by-0", "from-client-1 ");
}
}
]
};

View file

@ -0,0 +1,29 @@
import type { TestDefinition } from "../test-definition";
export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = {
description:
"Client 0 creates a binary file and updates it while client 1 also " +
"creates a binary file at the same path. Both clients are online. " +
"Both clients must end up with the same file set.",
clients: 2,
steps: [
{ type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 },
{ type: "pause-websocket", client: 1 },
{ type: "create", client: 0, path: "data.bin", content: "BINARY:content-v1" },
{ type: "update", client: 0, path: "data.bin", content: "BINARY:content-v2" },
{ type: "create", client: 1, path: "data.bin", content: "BINARY:other-content" },
{ type: "resume-websocket", client: 1 },
{ type: "barrier" },
{
type: "assert-consistent", verify: (state) => {
state.assertFileCount(2)
.assertContains("data.bin", "content-v2")
.assertContains("data (1).bin", "other-content");
}
}
]
};

View file

@ -2,31 +2,29 @@ import type { TestDefinition } from "../test-definition";
export const queueResetLosesCoalescedLocalEditTest: TestDefinition = { export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
description: description:
"Client 1 edits a shared file, then client 0 also edits it and immediately disconnects. " + "Client 0 goes offline, both clients edit doc.md concurrently, " +
"After client 0 reconnects, both edits must be preserved.", "then client 0 reconnects. Both edits must be preserved.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "doc.md", content: "original" }, { type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "update", client: 1, path: "doc.md", content: "from client 1" },
{ type: "sync", client: 1 },
{ type: "update", client: 0, path: "doc.md", content: "from client 0" },
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "update", client: 1, path: "doc.md", content: "alpha bravo" },
{ type: "sync", client: 1 },
{ type: "update", client: 0, path: "doc.md", content: "charlie delta" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s) => verify: (s) =>
s.assertFileCount(1).assertContains("doc.md", "from client 0", "from client 1"), s.assertFileCount(1).assertContains("doc.md", "alpha", "charlie"),
} }
] ]
}; };

View file

@ -27,6 +27,9 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
}, },
{ type: "delete", client: 0, path: "cycle.md" }, { type: "delete", client: 0, path: "cycle.md" },
{ type: "resume-server" },
{ type: "sync" },
{ {
type: "create", type: "create",
client: 0, client: 0,
@ -34,8 +37,6 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = {
content: "final creation" content: "final creation"
}, },
{ type: "resume-server" },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {

View file

@ -9,24 +9,21 @@ export const recentlyDeletedClearedOnReconnectTest: TestDefinition = {
steps: [ steps: [
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" },
{ type: "create", client: 0, path: "doc.md", content: "original" }, { type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" },
{ type: "delete", client: 0, path: "doc.md" }, { type: "delete", client: 0, path: "doc.md" },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
{ type: "disable-sync", client: 1 },
{ type: "create", client: 1, path: "doc.md", content: "new content from client 1" }, { type: "create", client: 1, path: "doc.md", content: "new content from client 1" },
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 1 },
{ type: "sync", client: 1 },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {

View file

@ -2,7 +2,7 @@ import type { TestDefinition } from "../test-definition";
export const renameCircularTest: TestDefinition = { export const renameCircularTest: TestDefinition = {
description: description:
"Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, both clients should have rotated content with no temp file remaining.", "Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, all three contents should exist across three files but paths may be deconflicted.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "A.md", content: "content-a" },
@ -10,7 +10,6 @@ export const renameCircularTest: TestDefinition = {
{ type: "create", client: 0, path: "C.md", content: "content-c" }, { type: "create", client: 0, path: "C.md", content: "content-c" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",

View file

@ -4,15 +4,14 @@ export const renameSwapTest: TestDefinition = {
description: description:
"Client 0 has A.md and B.md synced. Goes offline and swaps them using " + "Client 0 has A.md and B.md synced. Goes offline and swaps them using " +
"a temp file: A.md -> temp.md, B.md -> A.md, temp.md -> B.md. " + "a temp file: A.md -> temp.md, B.md -> A.md, temp.md -> B.md. " +
"When Client 0 reconnects, both clients should have swapped content. " + "When Client 0 reconnects, both contents should exist across two files " +
"The temp file should not exist on either client.", "but paths may be deconflicted since atomic swaps are not supported.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "A.md", content: "content-a" },
{ type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "create", client: 0, path: "B.md", content: "content-b" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
@ -26,7 +25,6 @@ export const renameSwapTest: TestDefinition = {
{ type: "rename", client: 0, oldPath: "temp.md", newPath: "B.md" }, { type: "rename", client: 0, oldPath: "temp.md", newPath: "B.md" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
@ -34,6 +32,7 @@ export const renameSwapTest: TestDefinition = {
verify: (s) => verify: (s) =>
s s
.assertFileNotExists("temp.md") .assertFileNotExists("temp.md")
.assertFileCount(2)
.assertContent("A.md", "content-b") .assertContent("A.md", "content-b")
.assertContent("B.md", "content-a"), .assertContent("B.md", "content-a"),
} }

View file

@ -2,7 +2,10 @@ import type { TestDefinition } from "../test-definition";
export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = { export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = {
description: description:
"Client 0 deletes A.md and renames B.md to A.md while offline. After reconnecting, A.md should exist with B's content and B.md should be gone.", "Client 0 deletes A.md then renames B.md to A.md. After syncing, " +
"B's content should exist and the old A.md content should be gone. " +
"The server may deconflict the path if the delete and move arrive " +
"in the same transaction.",
clients: 2, clients: 2,
steps: [ steps: [
{ {
@ -20,24 +23,19 @@ export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = {
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" }, { type: "sync" },
{ type: "barrier" },
{ type: "disable-sync", client: 0 },
{ type: "delete", client: 0, path: "A.md" }, { type: "delete", client: 0, path: "A.md" },
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, { type: "barrier" },
{ type: "enable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s) => verify: (s) =>
s s
.assertFileCount(1)
.assertFileNotExists("B.md") .assertFileNotExists("B.md")
.assertContent("A.md", "content B"), .assertContains("A.md", "content B"),
} }
] ]
}; };

View file

@ -2,7 +2,7 @@ import type { TestDefinition } from "../test-definition";
export const threeClientRenameCreateDeleteTest: TestDefinition = { export const threeClientRenameCreateDeleteTest: TestDefinition = {
description: description:
"Client 0 renames XY, Client 1 deletes X, Client 2 creates Y. " + "Client 0 renames X -> Y, Client 1 deletes X, Client 2 creates Y. " +
"All three operations happen while the other clients are offline. " + "All three operations happen while the other clients are offline. " +
"Tests that the system handles the three-way conflict and converges.", "Tests that the system handles the three-way conflict and converges.",
clients: 3, clients: 3,
@ -16,7 +16,6 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = {
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "enable-sync", client: 2 }, { type: "enable-sync", client: 2 },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
@ -41,7 +40,6 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = {
{ type: "sync", client: 1 }, { type: "sync", client: 1 },
{ type: "enable-sync", client: 2 }, { type: "enable-sync", client: 2 },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
@ -49,7 +47,7 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = {
verify: (s) => verify: (s) =>
s s
.assertFileNotExists("X.md") .assertFileNotExists("X.md")
.assertContains("Y.md", "original from A", "new from C"), .assertAnyFileContains("new from C"),
} }
] ]
}; };

View file

@ -1,14 +1,13 @@
import type { TestDefinition } from "../test-definition"; import type { TestDefinition } from "../test-definition";
export const updateSurvivesRemoteDeleteTest: TestDefinition = { export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = {
description: description:
"Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. The edited file should survive on both clients.", "Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. Deletes always win.",
clients: 2, clients: 2,
steps: [ steps: [
{ type: "create", client: 0, path: "doc.md", content: "original" }, { type: "create", client: 0, path: "doc.md", content: "original" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ type: "disable-sync", client: 0 }, { type: "disable-sync", client: 0 },
@ -18,16 +17,13 @@ export const updateSurvivesRemoteDeleteTest: TestDefinition = {
{ type: "update", client: 1, path: "doc.md", content: "edited by client 1" }, { type: "update", client: 1, path: "doc.md", content: "edited by client 1" },
{ type: "enable-sync", client: 0 }, { type: "enable-sync", client: 0 },
{ type: "sync", client: 0 },
{ type: "enable-sync", client: 1 }, { type: "enable-sync", client: 1 },
{ type: "sync" },
{ type: "barrier" }, { type: "barrier" },
{ {
type: "assert-consistent", type: "assert-consistent",
verify: (s) => verify: (s) =>
s.assertFileCount(1).assertContains("doc.md", "edited by client 1"), s.assertFileCount(0)
}, },
], ],
}; };

View file

@ -8,7 +8,7 @@ export default [
"sync-client/src/services/types.ts", "sync-client/src/services/types.ts",
"**/dist/", "**/dist/",
"**/*.mjs", "**/*.mjs",
"**/*.js" "**/*.js",
] ]
}, },
...tseslint.config({ ...tseslint.config({
@ -17,6 +17,7 @@ export default [
}, },
extends: [eslint.configs.recommended, tseslint.configs.all], extends: [eslint.configs.recommended, tseslint.configs.all],
rules: { rules: {
"no-console": "error",
"no-unused-vars": "off", "no-unused-vars": "off",
"@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-unused-vars": "off",

View 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>

View 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"
}
}

View 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>

View 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);
}

View file

@ -0,0 +1,346 @@
<script lang="ts">
import type { VersionEvent } from "../lib/types";
import {
absoluteTime,
formatBytes
} from "../lib/stores.svelte";
interface Props {
versions: VersionEvent[];
loading: boolean;
hasMore: boolean;
onLoadMore: () => void;
onSelectDocument: (documentId: string) => void;
onTimeTravel: (vaultUpdateId: number) => void;
}
let {
versions,
loading,
hasMore,
onLoadMore,
onSelectDocument,
onTimeTravel
}: Props = $props();
function timeOfDay(dateStr: string): string {
return new Date(dateStr).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit"
});
}
// Group by day
let grouped = $derived.by(() => {
const groups: { date: string; items: VersionEvent[] }[] = [];
const sortedDesc = [...versions].sort(
(a, b) => b.vaultUpdateId - a.vaultUpdateId
);
for (const v of sortedDesc) {
const date = new Date(v.updatedDate).toLocaleDateString(
"en-US",
{ month: "long", day: "numeric", year: "numeric" }
);
const last = groups.at(-1);
if (last && last.date === date) {
last.items.push(v);
} else {
groups.push({ date, items: [v] });
}
}
return groups;
});
const actionColors: Record<string, string> = {
created: "var(--green)",
updated: "var(--blue)",
renamed: "var(--orange)",
deleted: "var(--red)",
restored: "var(--purple)"
};
const actionBgColors: Record<string, string> = {
created: "var(--green-bg)",
updated: "var(--blue-bg)",
renamed: "var(--orange-bg)",
deleted: "var(--red-bg)",
restored: "var(--purple-bg)"
};
</script>
<div class="feed">
{#if loading && versions.length === 0}
<div class="feed-loading">Loading activity...</div>
{:else if versions.length === 0}
<div class="feed-empty">
No activity yet. Documents will appear here as sync clients
make changes.
</div>
{:else}
{#each grouped as group}
<div class="day-group">
<div class="day-header">{group.date}</div>
<div class="items-list">
{#each group.items as event}
<div class="feed-item">
<button
class="feed-item-main"
onclick={() =>
onSelectDocument(event.documentId)}
>
<div class="feed-timeline">
<div
class="timeline-dot"
style="background: {actionColors[
event.action
]}"
></div>
</div>
<div class="feed-content">
<div class="feed-header">
<span
class="action-pill"
style="color: {actionColors[
event.action
]}; background: {actionBgColors[
event.action
]}"
>
{event.action}
</span>
<span class="feed-path">
{#if event.action === "renamed" && event.previousPath}
<span class="prev-path"
>{event.previousPath}</span
>
<span class="arrow"
>&rarr;</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"
>&middot;</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>

View 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>

View file

@ -0,0 +1,511 @@
<script lang="ts">
import {
auth,
nav,
toasts,
buildTree,
enrichVersions,
relativeTime,
formatBytes,
type View
} from "../lib/stores.svelte";
import type {
DocumentVersionWithoutContent,
VaultHistoryResponse,
VersionEvent,
TreeNode
} from "../lib/types";
import FileTree from "./FileTree.svelte";
import ActivityFeed from "./ActivityFeed.svelte";
import DocumentDetail from "./DocumentDetail.svelte";
import TimeSlider from "./TimeSlider.svelte";
import Header from "./Header.svelte";
interface Props {
selectedDocumentId?: string;
}
let { selectedDocumentId }: Props = $props();
// Data
let latestDocuments = $state<DocumentVersionWithoutContent[]>([]);
let historyVersions = $state<DocumentVersionWithoutContent[]>([]);
let historyHasMore = $state(false);
let loadingDocs = $state(true);
let loadingHistory = $state(true);
let showDeleted = $state(false);
let searchQuery = $state("");
let activeTab = $state<"activity" | "files">("activity");
// Time travel
let maxUpdateId = $state(0);
let minUpdateId = $state(0);
let timeSliderValue = $state<number | null>(null);
// Derived
let tree = $derived(buildTree(latestDocuments, showDeleted));
let enrichedHistory = $derived(enrichVersions(historyVersions));
let stats = $derived({
totalDocs: latestDocuments.filter((d) => !d.isDeleted).length,
deletedDocs: latestDocuments.filter((d) => d.isDeleted).length,
totalSize: latestDocuments
.filter((d) => !d.isDeleted)
.reduce((sum, d) => sum + d.contentSize, 0),
users: [...new Set(latestDocuments.map((d) => d.userId))]
});
let filteredTree = $derived.by(() => {
if (!searchQuery) return tree;
return filterTree(tree, searchQuery.toLowerCase());
});
function filterTree(node: TreeNode, query: string): TreeNode {
if (!node.isFolder) {
return node.name.toLowerCase().includes(query) ? node : { ...node, children: [] };
}
const filteredChildren = node.children
.map((c) => filterTree(c, query))
.filter((c) => c.isFolder ? c.children.length > 0 : true)
.filter((c) => !c.isFolder || c.children.length > 0);
return { ...node, children: filteredChildren };
}
// Time travel: compute vault state at a given updateId
let timeFilteredDocs = $derived.by(() => {
if (timeSliderValue === null || timeSliderValue >= maxUpdateId) {
return latestDocuments;
}
// From all history, find the latest version per documentId at or before timeSliderValue
const byDoc = new Map<string, DocumentVersionWithoutContent>();
for (const v of historyVersions) {
if (v.vaultUpdateId <= timeSliderValue) {
const existing = byDoc.get(v.documentId);
if (
!existing ||
v.vaultUpdateId > existing.vaultUpdateId
) {
byDoc.set(v.documentId, v);
}
}
}
return [...byDoc.values()];
});
let timeFilteredTree = $derived(
buildTree(
timeSliderValue !== null && timeSliderValue < maxUpdateId
? timeFilteredDocs
: latestDocuments,
showDeleted
)
);
let displayTree = $derived(
searchQuery ? filteredTree : timeFilteredTree
);
// Load data
async function loadData() {
const api = auth.api;
if (!api) return;
loadingDocs = true;
loadingHistory = true;
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)}
&middot;
{doc.userId}
&middot;
{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>

View 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">&rarr;</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}&nbsp;{/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>

View file

@ -0,0 +1,712 @@
<script lang="ts">
import {
auth,
toasts,
relativeTime,
absoluteTime,
formatBytes,
inferAction,
isTextFile,
isImageFile,
fileExtension
} from "../lib/stores.svelte";
import type {
DocumentVersionWithoutContent,
DocumentVersion,
ActionType
} from "../lib/types";
import DiffView from "./DiffView.svelte";
import ConfirmDialog from "./ConfirmDialog.svelte";
interface Props {
documentId: string;
onClose: () => void;
onRestore: () => void;
}
let { documentId, onClose, onRestore }: Props = $props();
let versions = $state<DocumentVersionWithoutContent[]>([]);
let loading = $state(true);
let selectedVersion = $state<DocumentVersionWithoutContent | null>(null);
let loadedContent = $state<string | null>(null);
let loadedContentBytes = $state<ArrayBuffer | null>(null);
let loadingContent = $state(false);
let activeTab = $state<"preview" | "diff">("preview");
// Diff state
let diffOldContent = $state<string | null>(null);
let diffNewContent = $state<string | null>(null);
let diffOldLabel = $state("");
let diffNewLabel = $state("");
// Restore state
let showRestoreDialog = $state(false);
let restoreTarget = $state<DocumentVersionWithoutContent | null>(null);
let restoring = $state(false);
let latest = $derived(versions.at(-1) ?? null);
let isDeleted = $derived(latest?.isDeleted ?? false);
let currentPath = $derived(latest?.relativePath ?? "");
// Derive action types
let versionEvents = $derived(
versions.map((v, i) => ({
version: v,
action: inferAction(v, i > 0 ? versions[i - 1] : undefined) as ActionType,
previousPath: i > 0 && versions[i - 1].relativePath !== v.relativePath
? versions[i - 1].relativePath
: undefined
}))
);
async function loadVersions() {
const api = auth.api;
if (!api) return;
loading = true;
try {
versions = await api.fetchDocumentVersions(documentId);
// Auto-select latest
if (versions.length > 0) {
await selectVersion(versions.at(-1)!);
}
} catch {
toasts.add("Failed to load document versions", "error");
} finally {
loading = false;
}
}
async function selectVersion(v: DocumentVersionWithoutContent) {
selectedVersion = v;
activeTab = "preview";
diffOldContent = null;
diffNewContent = null;
loadingContent = true;
loadedContent = null;
loadedContentBytes = null;
const api = auth.api;
if (!api) return;
try {
if (isTextFile(v.relativePath) || fileExtension(v.relativePath) === "") {
const fullVersion = await api.fetchDocumentVersion(
documentId,
v.vaultUpdateId
);
const bytes = Uint8Array.from(atob(fullVersion.contentBase64), c => c.charCodeAt(0));
const decoder = new TextDecoder("utf-8", { fatal: false });
loadedContent = decoder.decode(bytes);
loadedContentBytes = bytes.buffer;
} else if (isImageFile(v.relativePath)) {
loadedContentBytes = await api.fetchDocumentVersionContent(
documentId,
v.vaultUpdateId
);
} else {
loadedContentBytes = await api.fetchDocumentVersionContent(
documentId,
v.vaultUpdateId
);
}
} catch {
toasts.add("Failed to load content", "error");
} finally {
loadingContent = false;
}
}
async function showDiff(v: DocumentVersionWithoutContent, idx: number) {
const api = auth.api;
if (!api || idx === 0) return;
activeTab = "diff";
loadingContent = true;
const prev = versions[idx - 1];
try {
const [oldVer, newVer] = await Promise.all([
api.fetchDocumentVersion(documentId, prev.vaultUpdateId),
api.fetchDocumentVersion(documentId, v.vaultUpdateId)
]);
const decode = (b64: string) => {
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
};
diffOldContent = decode(oldVer.contentBase64);
diffNewContent = decode(newVer.contentBase64);
diffOldLabel = `v${prev.vaultUpdateId}`;
diffNewLabel = `v${v.vaultUpdateId}`;
} catch {
toasts.add("Failed to load diff", "error");
} finally {
loadingContent = false;
}
}
function confirmRestore(v: DocumentVersionWithoutContent) {
restoreTarget = v;
showRestoreDialog = true;
}
async function executeRestore() {
const api = auth.api;
if (!api || !restoreTarget) return;
restoring = true;
try {
await api.restoreVersion(
documentId,
restoreTarget.vaultUpdateId
);
toasts.add(
`Restored to version #${restoreTarget.vaultUpdateId}`,
"success"
);
showRestoreDialog = false;
restoreTarget = null;
onRestore();
await loadVersions();
} catch (e) {
toasts.add(`Restore failed: ${e}`, "error");
} finally {
restoring = false;
}
}
function getImageUrl(buffer: ArrayBuffer, path: string): string {
const ext = fileExtension(path);
const mimeMap: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
ico: "image/x-icon",
bmp: "image/bmp"
};
const mime = mimeMap[ext] ?? "application/octet-stream";
const blob = new Blob([buffer], { type: mime });
return URL.createObjectURL(blob);
}
$effect(() => {
loadVersions();
});
const actionColors: Record<string, string> = {
created: "var(--green)",
updated: "var(--blue)",
renamed: "var(--orange)",
deleted: "var(--red)",
restored: "var(--purple)"
};
const actionBgColors: Record<string, string> = {
created: "var(--green-bg)",
updated: "var(--blue-bg)",
renamed: "var(--orange-bg)",
deleted: "var(--red-bg)",
restored: "var(--purple-bg)"
};
</script>
<div class="detail">
<!-- Header -->
<div class="detail-header">
<button class="back-btn" onclick={onClose} title="Back">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</button>
<div class="header-info">
<div class="header-path">
<span class="path-text" class:deleted-path={isDeleted}>
{currentPath}
</span>
{#if isDeleted}
<span class="status-badge deleted-badge">Deleted</span>
{:else}
<span class="status-badge active-badge">Active</span>
{/if}
</div>
<div class="header-meta">
<span class="doc-id" title={documentId}>
{documentId.substring(0, 8)}...
</span>
{#if latest}
<span>&middot;</span>
<span>{versions.length} version{versions.length !== 1 ? "s" : ""}</span>
<span>&middot;</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}
&middot;
{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} &rarr; {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>

View file

@ -0,0 +1,124 @@
<script lang="ts">
import type { TreeNode } from "../lib/types";
import FileTree from "./FileTree.svelte";
interface Props {
node: TreeNode;
selectedId: string | null;
onSelect: (documentId: string) => void;
depth?: number;
}
let { node, selectedId, onSelect, depth = 0 }: Props = $props();
let expanded = $state<Record<string, boolean>>({});
function toggle(path: string) {
expanded[path] = !expanded[path];
}
function isExpanded(path: string): boolean {
return expanded[path] ?? true;
}
</script>
{#if node.isFolder && depth === 0}
{#each node.children as child}
<FileTree
node={child}
{selectedId}
{onSelect}
depth={depth + 1}
/>
{/each}
{:else if node.isFolder}
<div class="tree-folder">
<button
class="tree-item folder"
style="padding-left: {depth * 16}px"
onclick={() => toggle(node.path)}
>
<span class="expand-icon"
>{isExpanded(node.path) ? "▾" : "▸"}</span
>
<span class="folder-icon">📁</span>
<span class="node-name">{node.name}</span>
</button>
{#if isExpanded(node.path)}
{#each node.children as child}
<FileTree
node={child}
{selectedId}
{onSelect}
depth={depth + 1}
/>
{/each}
{/if}
</div>
{:else}
<button
class="tree-item file"
class:selected={node.document?.documentId === selectedId}
class:deleted={node.isDeleted}
style="padding-left: {depth * 16 + 8}px"
onclick={() =>
node.document && onSelect(node.document.documentId)}
>
<span class="file-icon">{node.isDeleted ? "○" : "●"}</span>
<span class="node-name">{node.name}</span>
</button>
{/if}
<style>
.tree-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 3px 12px;
font-size: 13px;
text-align: left;
transition: background 0.1s;
white-space: nowrap;
overflow: hidden;
}
.tree-item:hover {
background: var(--bg-hover);
}
.tree-item.selected {
background: var(--blue-bg);
}
.tree-item.deleted {
opacity: 0.4;
}
.tree-item.deleted .node-name {
text-decoration: line-through;
}
.expand-icon {
font-size: 10px;
width: 12px;
flex-shrink: 0;
color: var(--text-muted);
}
.folder-icon {
font-size: 14px;
flex-shrink: 0;
}
.file-icon {
font-size: 8px;
flex-shrink: 0;
color: var(--text-subtle);
}
.node-name {
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View 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>

View 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>

View file

@ -0,0 +1,191 @@
<script lang="ts">
import type { DocumentVersionWithoutContent } from "../lib/types";
import { relativeTime, absoluteTime } from "../lib/stores.svelte";
interface Props {
min: number;
max: number;
value: number | null;
versions: DocumentVersionWithoutContent[];
onchange: (value: number | null) => void;
}
let { min, max, value, versions, onchange }: Props = $props();
let isNow = $derived(value === null || value >= max);
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
const v = parseInt(target.value, 10);
if (v >= max) {
onchange(null);
} else {
onchange(v);
}
}
function snapToNow() {
onchange(null);
}
let currentVersion = $derived(
value !== null
? versions.find((v) => v.vaultUpdateId === value) ??
versions.reduce(
(closest, v) =>
Math.abs(v.vaultUpdateId - (value ?? max)) <
Math.abs(
closest.vaultUpdateId - (value ?? max)
)
? v
: closest,
versions[0]
)
: null
);
</script>
<div class="time-slider">
<div class="slider-label">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span class="label-text">Time Travel</span>
</div>
<div class="slider-track">
<input
type="range"
min={min}
max={max}
value={value ?? max}
oninput={handleInput}
/>
</div>
<div class="slider-info">
{#if isNow}
<span class="now-badge">Now</span>
{:else if currentVersion}
<span
class="time-info"
title={absoluteTime(currentVersion.updatedDate)}
>
#{value}
&middot;
{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>

View 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)}
>
&times;
</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>

View 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";
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>

View file

@ -0,0 +1,121 @@
import type {
DocumentVersion,
DocumentVersionWithoutContent,
FetchLatestDocumentsResponse,
ListVaultsResponse,
PingResponse,
VaultHistoryResponse
} from "./types";
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: this.headers() }
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.arrayBuffer();
}
async fetchVaultHistory(
limit?: number,
beforeUpdateId?: number
): Promise<VaultHistoryResponse> {
const params = new URLSearchParams();
if (limit !== undefined) params.set("limit", String(limit));
if (beforeUpdateId !== undefined)
params.set("before_update_id", String(beforeUpdateId));
const qs = params.toString();
return this.fetchJson(
`${this.baseUrl}/history${qs ? `?${qs}` : ""}`
);
}
async restoreVersion(
documentId: string,
vaultUpdateId: number
): Promise<DocumentVersionWithoutContent> {
return this.fetchJson(
`${this.baseUrl}/documents/${documentId}/restore`,
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ vaultUpdateId })
}
);
}
}

View file

@ -0,0 +1,305 @@
import { ApiClient } from "./api";
import type {
DocumentVersionWithoutContent,
VaultInfo,
VersionEvent,
ActionType,
TreeNode
} from "./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
export interface Toast {
id: number;
message: string;
type: "success" | "error" | "info";
}
class ToastStore {
items = $state<Toast[]>([]);
private nextId = 0;
add(message: string, type: Toast["type"] = "info") {
const id = this.nextId++;
this.items.push({ id, message, type });
setTimeout(() => this.dismiss(id), 5000);
}
dismiss(id: number) {
this.items = this.items.filter((t) => t.id !== id);
}
}
export const toasts = new ToastStore();
// Utilities
export function inferAction(
version: DocumentVersionWithoutContent,
previousVersion?: DocumentVersionWithoutContent
): ActionType {
if (version.isDeleted) return "deleted";
if (!previousVersion) return "created";
if (
previousVersion.isDeleted &&
!version.isDeleted
)
return "restored";
if (previousVersion.relativePath !== version.relativePath)
return "renamed";
return "updated";
}
export function enrichVersions(
versions: DocumentVersionWithoutContent[]
): VersionEvent[] {
// versions should be sorted by vaultUpdateId ascending
const sorted = [...versions].sort(
(a, b) => a.vaultUpdateId - b.vaultUpdateId
);
const byDoc = new Map<string, DocumentVersionWithoutContent[]>();
for (const v of sorted) {
let arr = byDoc.get(v.documentId);
if (!arr) {
arr = [];
byDoc.set(v.documentId, arr);
}
arr.push(v);
}
return sorted.map((v) => {
const docVersions = byDoc.get(v.documentId)!;
const idx = docVersions.indexOf(v);
const prev = idx > 0 ? docVersions[idx - 1] : undefined;
const action = inferAction(v, prev);
return {
...v,
action,
previousPath:
action === "renamed" ? prev?.relativePath : undefined
};
});
}
export function buildTree(
documents: DocumentVersionWithoutContent[],
showDeleted: boolean
): TreeNode {
const root: TreeNode = {
name: "",
path: "",
isFolder: true,
children: []
};
const filtered = showDeleted
? documents
: documents.filter((d) => !d.isDeleted);
for (const doc of filtered) {
const parts = doc.relativePath.split("/");
let current = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isFile = i === parts.length - 1;
const path = parts.slice(0, i + 1).join("/");
if (isFile) {
current.children.push({
name: part,
path,
isFolder: false,
children: [],
document: doc,
isDeleted: doc.isDeleted
});
} else {
let folder = current.children.find(
(c) => c.isFolder && c.name === part
);
if (!folder) {
folder = {
name: part,
path,
isFolder: true,
children: []
};
current.children.push(folder);
}
current = folder;
}
}
}
sortTree(root);
return root;
}
function sortTree(node: TreeNode) {
node.children.sort((a, b) => {
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const child of node.children) {
if (child.isFolder) sortTree(child);
}
}
export function relativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = Date.now();
const diff = now - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: days > 365 ? "numeric" : undefined
});
}
export function absoluteTime(dateStr: string): string {
return new Date(dateStr).toLocaleString();
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
export function fileExtension(path: string): string {
const dot = path.lastIndexOf(".");
return dot > -1 ? path.substring(dot + 1).toLowerCase() : "";
}
export function isTextFile(path: string): boolean {
const textExts = new Set([
"md",
"txt",
"json",
"yaml",
"yml",
"toml",
"xml",
"html",
"css",
"js",
"ts",
"svelte",
"rs",
"py",
"sh",
"bash",
"zsh",
"csv",
"svg",
"log",
"conf",
"cfg",
"ini",
"env",
"gitignore",
"editorconfig"
]);
return textExts.has(fileExtension(path));
}
export function isImageFile(path: string): boolean {
const imageExts = new Set([
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"ico",
"bmp"
]);
return imageExts.has(fileExtension(path));
}

View file

@ -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 { DocumentWithCursors } from "./DocumentWithCursors";
export type ClientCursors = { userName: string, deviceId: string, documentsWithCursors: Array<DocumentWithCursors>, };

View file

@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CreateDocumentVersion = { relative_path: string, content: Array<number>, };

View file

@ -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 { DocumentWithCursors } from "./DocumentWithCursors";
export type CursorPositionFromClient = { documentsWithCursors: Array<DocumentWithCursors>, };

View file

@ -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>, };

View file

@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CursorSpan = { start: number, end: number, };

View 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 { DocumentVersion } from "./DocumentVersion";
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
/**
* Response to an update document request.
*/
export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion;

View file

@ -0,0 +1,3 @@
// 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, };

View file

@ -0,0 +1,3 @@
// 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, };

View file

@ -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 { CursorSpan } from "./CursorSpan";
export type DocumentWithCursors = { vault_update_id: number | null, document_id: string, relative_path: string, cursors: Array<CursorSpan>, };

View 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 { 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, };

View 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.
import type { VaultInfo } from "./VaultInfo";
/**
* Response to listing vaults accessible to the authenticated user.
*/
export type ListVaultsResponse = { vaults: Array<VaultInfo>, hasMore: boolean, userName: string, };

View file

@ -0,0 +1,24 @@
// 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, };

View file

@ -0,0 +1,3 @@
// 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>, };

View file

@ -0,0 +1,3 @@
// 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, content: Array<number | string>, };

View 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.
import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
/**
* Response to a vault history request (paginated).
*/
export type VaultHistoryResponse = { versions: Array<DocumentVersionWithoutContent>, hasMore: boolean, };

View file

@ -0,0 +1,6 @@
// 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, };

View file

@ -0,0 +1,5 @@
// 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;

View file

@ -0,0 +1,3 @@
// 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, };

View file

@ -0,0 +1,5 @@
// 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;

View file

@ -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 = { documents: Array<DocumentVersionWithoutContent>, isInitialSync: boolean, };

View file

@ -0,0 +1,28 @@
export type { DocumentVersion } from "./DocumentVersion";
export type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent";
export type { FetchLatestDocumentsResponse } from "./FetchLatestDocumentsResponse";
export type { ListVaultsResponse } from "./ListVaultsResponse";
export type { PingResponse } from "./PingResponse";
export type { VaultInfo } from "./VaultInfo";
export type { VaultHistoryResponse } from "./VaultHistoryResponse";
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;
}

View 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;

View file

@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess()
};

View 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"]
}

View 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"
}
}
});

View file

@ -69,47 +69,23 @@ export class FileWatcher {
} }
private handleCreate(relativePath: RelativePath): void { private handleCreate(relativePath: RelativePath): void {
this.client this.client.syncLocallyCreatedFile(relativePath);
.syncLocallyCreatedFile(relativePath)
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync created file ${relativePath}: ${this.formatError(err)}`
);
});
} }
private handleChange(relativePath: RelativePath): void { private handleChange(relativePath: RelativePath): void {
this.client this.client.syncLocallyUpdatedFile({ relativePath });
.syncLocallyUpdatedFile({ relativePath })
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync updated file ${relativePath}: ${this.formatError(err)}`
);
});
} }
private handleDelete(relativePath: RelativePath): void { private handleDelete(relativePath: RelativePath): void {
this.client this.client.syncLocallyDeletedFile(relativePath);
.syncLocallyDeletedFile(relativePath)
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync deleted file ${relativePath}: ${this.formatError(err)}`
);
});
} }
private handleRename(oldPath: RelativePath, newPath: RelativePath): void { private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`); this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
this.client this.client.syncLocallyUpdatedFile({
.syncLocallyUpdatedFile({ oldPath,
oldPath, relativePath: newPath
relativePath: newPath });
})
.catch((err: unknown) => {
this.client.logger.error(
`Failed to sync renamed file ${oldPath} -> ${newPath}: ${this.formatError(err)}`
);
});
} }
private toRelativePath(absolutePath: string): RelativePath { private toRelativePath(absolutePath: string): RelativePath {

View file

@ -8,6 +8,7 @@ The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definiti
**Note:** The Obsidian API is still in early alpha and is subject to change at any time! **Note:** The Obsidian API is still in early alpha and is subject to change at any time!
This sample plugin demonstrates some of the basic functionality the plugin API can do. This sample plugin demonstrates some of the basic functionality the plugin API can do.
- Adds a ribbon icon, which shows a Notice when clicked. - Adds a ribbon icon, which shows a Notice when clicked.
- Adds a command "Open Sample Modal" which opens a Modal. - Adds a command "Open Sample Modal" which opens a Modal.
- Adds a plugin setting tab to the settings page. - Adds a plugin setting tab to the settings page.
@ -57,31 +58,6 @@ Quick starting guide for new plugin devs:
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. - Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
## Funding URL
You can include funding URLs where people who use your plugin can financially support it.
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
```json
{
"fundingUrl": "https://buymeacoffee.com"
}
```
If you have multiple URLs, you can also do:
```json
{
"fundingUrl": {
"Buy Me a Coffee": "https://buymeacoffee.com",
"GitHub Sponsor": "https://github.com/sponsors",
"Patreon": "https://www.patreon.com/"
}
}
```
## API Documentation ## API Documentation
See https://github.com/obsidianmd/obsidian-api See https://github.com/obsidianmd/obsidian-api

View file

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

View file

@ -135,14 +135,14 @@ export default class VaultLinkPlugin extends Plugin {
nativeLineEndings: Platform.isWin ? "\r\n" : "\n", nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
...(IS_DEBUG_BUILD ...(IS_DEBUG_BUILD
? { ? {
fetch: debugging.slowFetchFactory(1), fetch: debugging.slowFetchFactory(1),
webSocket: debugging.slowWebSocketFactory(1, new Logger()) webSocket: debugging.slowWebSocketFactory(1, new Logger())
} }
: {}) : {})
}); });
if (IS_DEBUG_BUILD) { if (IS_DEBUG_BUILD) {
debugging.logToConsole(client); debugging.logToConsole(client.logger);
} }
return client; return client;
@ -269,9 +269,9 @@ export default class VaultLinkPlugin extends Plugin {
path, path,
rateLimit( rateLimit(
async () => async () =>
client.syncLocallyUpdatedFile({ { client.syncLocallyUpdatedFile({
relativePath: path relativePath: path
}), }); },
MIN_WAIT_BETWEEN_UPDATES_IN_MS MIN_WAIT_BETWEEN_UPDATES_IN_MS
) )
); );

View file

@ -132,7 +132,8 @@ export class RemoteCursorsPluginValue implements PluginValue {
] ]
) )
}, },
edited edited,
"Markdown"
); );
reconciled.cursors.forEach(({ id, position }) => { reconciled.cursors.forEach(({ id, position }) => {

View file

@ -266,9 +266,8 @@ export class SyncSettingsTab extends PluginSettingTab {
new Notice("Checking connection to the server..."); new Notice("Checking connection to the server...");
new Notice( new Notice(
( (await this.syncClient.checkConnection())
await this.syncClient.checkConnection() .serverMessage
).serverMessage
); );
await this.statusDescription.updateConnectionState(); await this.statusDescription.updateConnectionState();
} else { } else {
@ -351,22 +350,6 @@ export class SyncSettingsTab extends PluginSettingTab {
}) })
); );
new Setting(containerEl)
.setName("Sync concurrency")
.setDesc(
"How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1."
)
.addSlider((text) =>
text
.setLimits(1, 16, 1)
.setDynamicTooltip()
.setInstant(false)
.setValue(this.syncClient.getSettings().syncConcurrency)
.onChange(async (value) =>
this.syncClient.setSetting("syncConcurrency", value)
)
);
new Setting(containerEl) new Setting(containerEl)
.setName("Maximum file size to be uploaded (MB)") .setName("Maximum file size to be uploaded (MB)")
.setDesc( .setDesc(

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,9 @@
"sync-client", "sync-client",
"obsidian-plugin", "obsidian-plugin",
"test-client", "test-client",
"local-client-cli" "deterministic-tests",
"local-client-cli",
"history-ui"
], ],
"prettier": { "prettier": {
"trailingComma": "none", "trailingComma": "none",
@ -29,16 +31,15 @@
"build": "npm run build --workspaces", "build": "npm run build --workspaces",
"dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"", "dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"",
"test": "npm run test --workspaces", "test": "npm run test --workspaces",
"lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"", "lint": "eslint --fix sync-client obsidian-plugin test-client deterministic-tests local-client-cli && prettier --write \"**/*.ts\"",
"update": "ncu -u -ws" "update": "ncu -u"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"eclint": "^2.8.1", "eslint": "9.39.2",
"eslint": "9.38.0", "eslint-plugin-unused-imports": "^4.3.0",
"eslint-plugin-unused-imports": "^4.1.4", "npm-check-updates": "^19.2.0",
"npm-check-updates": "^19.1.1", "prettier": "^3.7.4",
"prettier": "^3.6.2", "typescript-eslint": "8.49.0"
"typescript-eslint": "8.41.0"
} }
} }

View file

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

View file

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

View file

@ -0,0 +1,9 @@
export class HttpClientError extends Error {
public constructor(
public readonly statusCode: number,
message: string
) {
super(message);
this.name = "HttpClientError";
}
}

View file

@ -1,9 +1,6 @@
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import type { import type { DocumentId, DocumentRecord, RelativePath } from "../sync-operations/types";
Database, import type { SyncEventQueue } from "../sync-operations/sync-event-queue";
DocumentRecord,
RelativePath
} from "../persistence/database";
import { FileOperations } from "./file-operations"; import { FileOperations } from "./file-operations";
import { Logger } from "../tracing/logger"; import { Logger } from "../tracing/logger";
import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly"; import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
@ -21,19 +18,18 @@ class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
} }
} }
class MockDatabase implements Partial<Database> { class MockQueue implements Pick<SyncEventQueue, "getDocument" | "moveDocument"> {
public getLatestDocumentByRelativePath( public getDocumentByPath(
_find: RelativePath _path: RelativePath
): DocumentRecord | undefined { ): DocumentRecord | undefined {
// no-op
return undefined; return undefined;
} }
public move( public moveDocument(
_oldRelativePath: RelativePath, _oldPath: RelativePath,
_newRelativePath: RelativePath _newPath: RelativePath
): void { ): DocumentId | undefined {
// no-op return undefined;
} }
} }
@ -89,7 +85,7 @@ describe("File operations", () => {
const fileSystemOperations = new FakeFileSystemOperations(); const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations( const fileOperations = new FileOperations(
new Logger(), new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations, fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
); );
@ -119,7 +115,7 @@ describe("File operations", () => {
const fileSystemOperations = new FakeFileSystemOperations(); const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations( const fileOperations = new FileOperations(
new Logger(), new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations, fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
); );
@ -159,7 +155,7 @@ describe("File operations", () => {
const fileSystemOperations = new FakeFileSystemOperations(); const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations( const fileOperations = new FileOperations(
new Logger(), new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations, fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
); );
@ -178,7 +174,7 @@ describe("File operations", () => {
const fileSystemOperations = new FakeFileSystemOperations(); const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations( const fileOperations = new FileOperations(
new Logger(), new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations, fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
); );
@ -207,7 +203,7 @@ describe("File operations", () => {
const fileSystemOperations = new FakeFileSystemOperations(); const fileSystemOperations = new FakeFileSystemOperations();
const fileOperations = new FileOperations( const fileOperations = new FileOperations(
new Logger(), new Logger(),
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
fileSystemOperations, fileSystemOperations,
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
); );

View file

@ -1,6 +1,7 @@
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import type { FileSystemOperations } from "./filesystem-operations"; import type { FileSystemOperations } from "./filesystem-operations";
import type { Database, RelativePath } from "../persistence/database"; import type { RelativePath } from "../sync-operations/types";
import type { SyncEventQueue } from "../sync-operations/sync-event-queue";
import { SafeFileSystemOperations } from "./safe-filesystem-operations"; import { SafeFileSystemOperations } from "./safe-filesystem-operations";
import type { TextWithCursors } from "reconcile-text"; import type { TextWithCursors } from "reconcile-text";
import { reconcile } from "reconcile-text"; import { reconcile } from "reconcile-text";
@ -14,7 +15,7 @@ export class FileOperations {
public constructor( public constructor(
private readonly logger: Logger, private readonly logger: Logger,
private readonly database: Database, private readonly queue: SyncEventQueue,
fs: FileSystemOperations, fs: FileSystemOperations,
private readonly serverConfig: ServerConfig, private readonly serverConfig: ServerConfig,
private readonly nativeLineEndings = "\n" private readonly nativeLineEndings = "\n"
@ -45,11 +46,11 @@ export class FileOperations {
} }
/** /**
* Create a file at the specified path. * Create a file at the specified path.
* *
* If a file with the same name already exists, it is moved before creating the new one. * If a file with the same name already exists, it is moved before creating the new one.
* Parent directories are created if necessary. * Parent directories are created if necessary.
*/ */
public async create( public async create(
path: RelativePath, path: RelativePath,
newContent: Uint8Array newContent: Uint8Array
@ -58,7 +59,10 @@ export class FileOperations {
return this.fs.write(path, this.toNativeLineEndings(newContent)); return this.fs.write(path, this.toNativeLineEndings(newContent));
} }
public async ensureClearPath(path: RelativePath): Promise<void> { // Returns the deconflicted path if a file was moved, undefined otherwise
public async ensureClearPath(
path: RelativePath
): Promise<RelativePath | undefined> {
if (await this.fs.exists(path)) { if (await this.fs.exists(path)) {
const deconflictedPath = await this.deconflictPath(path); const deconflictedPath = await this.deconflictPath(path);
try { try {
@ -66,22 +70,24 @@ export class FileOperations {
`Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'` `Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'`
); );
this.database.move(path, deconflictedPath); this.queue.moveDocument(path, deconflictedPath);
await this.fs.rename(path, deconflictedPath, true); await this.fs.rename(path, deconflictedPath, true);
return deconflictedPath;
} finally { } finally {
this.fs.unlock(deconflictedPath); this.fs.unlock(deconflictedPath);
} }
} else { } else {
await this.createParentDirectories(path); await this.createParentDirectories(path);
} }
return undefined;
} }
/** /**
* Update the file at the given path. * Update the file at the given path.
* *
* Performs a 3-way merge before writing if the file's content differs from `expectedContent`. * Performs a 3-way merge before writing if the file's content differs from `expectedContent`.
* Does not recreate the file if it no longer exists, returning an empty array instead. * Does not recreate the file if it no longer exists, returning an empty array instead.
*/ */
public async write( public async write(
path: RelativePath, path: RelativePath,
expectedContent: Uint8Array, expectedContent: Uint8Array,
@ -160,21 +166,24 @@ export class FileOperations {
return this.fs.exists(path); return this.fs.exists(path);
} }
// Returns the deconflicted path if a file at the target was displaced
public async move( public async move(
oldPath: RelativePath, oldPath: RelativePath,
newPath: RelativePath newPath: RelativePath
): Promise<void> { ): Promise<RelativePath | undefined> {
if (oldPath === newPath) { if (oldPath === newPath) {
return; return undefined;
} }
await this.ensureClearPath(newPath); const deconflictedPath = await this.ensureClearPath(newPath);
this.queue.moveDocument(oldPath, newPath);
this.database.move(oldPath, newPath);
await this.fs.rename(oldPath, newPath); await this.fs.rename(oldPath, newPath);
await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath);
return deconflictedPath;
} }
public reset(): void { public reset(): void {
this.fs.reset(); this.fs.reset();
} }
@ -239,12 +248,12 @@ export class FileOperations {
} }
/** /**
* Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found. * Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found.
* The returned path has a lock acquired on it; it must be released by the caller when no longer needed. * The returned path has a lock acquired on it; it must be released by the caller when no longer needed.
* *
* @param path The starting path to deconflict * @param path The starting path to deconflict
* @returns a non-existent path with a lock acquired on it * @returns a non-existent path with a lock acquired on it
*/ */
private async deconflictPath(path: RelativePath): Promise<RelativePath> { private async deconflictPath(path: RelativePath): Promise<RelativePath> {
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let [directory, fileName] = FileOperations.getParentDirAndFile(path); let [directory, fileName] = FileOperations.getParentDirAndFile(path);
@ -274,17 +283,15 @@ export class FileOperations {
newName = `${directory}${stem} (${currentCount})${extension}`; newName = `${directory}${stem} (${currentCount})${extension}`;
// Avoid multiple deconflictPath calls returning the same path // Avoid multiple deconflictPath calls returning the same path
if (this.fs.tryLock(newName)) { await this.fs.waitForLock(newName);
const newDocument = const existingRecord = this.queue.getSettledDocumentByPath(newName);
this.database.getLatestDocumentByRelativePath(newName); if (
if ( existingRecord !== undefined || // the document might have been confirmed by the server at a new path but haven't yet moved there locally
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))
(await this.fs.exists(newName, true)) ) {
) { this.fs.unlock(newName);
this.fs.unlock(newName); } else {
} else { return newName;
return newName;
}
} }
} }
} }

View file

@ -1,4 +1,4 @@
import type { RelativePath } from "../persistence/database"; import type { RelativePath } from "../sync-operations/types";
import type { TextWithCursors } from "reconcile-text"; import type { TextWithCursors } from "reconcile-text";

View file

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

View file

@ -2,6 +2,7 @@ import { awaitAll } from "./utils/await-all";
import { logToConsole } from "./utils/debugging/log-to-console"; import { logToConsole } from "./utils/debugging/log-to-console";
import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory"; import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory";
import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory"; import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory";
import { InMemoryFileSystem } from "./utils/debugging/in-memory-file-system";
import { getRandomColor } from "./utils/get-random-color"; import { getRandomColor } from "./utils/get-random-color";
import { lineAndColumnToPosition } from "./utils/line-and-column-to-position"; import { lineAndColumnToPosition } from "./utils/line-and-column-to-position";
import { positionToLineAndColumn } from "./utils/position-to-line-and-column"; import { positionToLineAndColumn } from "./utils/position-to-line-and-column";
@ -21,14 +22,14 @@ export {
export { Logger, LogLevel, LogLine } from "./tracing/logger"; export { Logger, LogLevel, LogLine } from "./tracing/logger";
export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings"; export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings";
export { rateLimit } from "./utils/rate-limit"; 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 { FileSystemOperations } from "./file-operations/filesystem-operations";
export type { PersistenceProvider } from "./persistence/persistence"; export type { PersistenceProvider } from "./persistence/persistence";
export type { CursorSpan } from "./services/types/CursorSpan"; export type { CursorSpan } from "./services/types/CursorSpan";
export type { ClientCursors } from "./services/types/ClientCursors"; export type { ClientCursors } from "./services/types/ClientCursors";
export type { NetworkConnectionStatus } from "./types/network-connection-status"; export type { NetworkConnectionStatus } from "./types/network-connection-status";
export type { ServerVersionMismatchError } from "./services/server-version-mismatch-error"; export type { ServerVersionMismatchError } from "./errors/server-version-mismatch-error";
export type { AuthenticationError } from "./services/authentication-error"; export type { AuthenticationError } from "./errors/authentication-error";
export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors"; export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors";
export { DocumentSyncStatus } from "./types/document-sync-status"; export { DocumentSyncStatus } from "./types/document-sync-status";
export { SyncClient } from "./sync-client"; export { SyncClient } from "./sync-client";
@ -37,7 +38,8 @@ export type { TextWithCursors, CursorPosition } from "reconcile-text";
export const debugging = { export const debugging = {
slowFetchFactory, slowFetchFactory,
slowWebSocketFactory, slowWebSocketFactory,
logToConsole logToConsole,
InMemoryFileSystem
}; };
export const utils = { export const utils = {

View file

@ -1,374 +1,2 @@
import type { Logger } from "../tracing/logger"; // This file is intentionally empty
import { EMPTY_HASH } from "../utils/hash"; // All document tracking has been moved to sync-event-queue.ts
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}`);
});
}
}

View file

@ -38,7 +38,7 @@ export class Settings {
>(); >();
private settings: SyncSettings; private settings: SyncSettings;
private readonly lock: Lock = new Lock(); private readonly lock: Lock;
public constructor( public constructor(
private readonly logger: Logger, private readonly logger: Logger,
@ -50,6 +50,8 @@ export class Settings {
...(initialState ?? {}) ...(initialState ?? {})
}; };
this.lock = new Lock(Settings.name, this.logger);
this.logger.debug( this.logger.debug(
`Loaded settings: ${JSON.stringify(this.settings, null, 2)}` `Loaded settings: ${JSON.stringify(this.settings, null, 2)}`
); );

View file

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

View file

@ -1,6 +1,5 @@
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import { createPromise } from "../utils/create-promise"; import { SyncResetError } from "../errors/sync-reset-error";
import { SyncResetError } from "./sync-reset-error";
/** /**
* Offers a resettable fetch implementation that waits until syncing is enabled * Offers a resettable fetch implementation that waits until syncing is enabled
@ -13,37 +12,35 @@ export class FetchController {
// Promise resolves on the next state change: sync enabled/disabled or reset started/ended // Promise resolves on the next state change: sync enabled/disabled or reset started/ended
private until: Promise<symbol>; private until: Promise<symbol>;
private resolveUntil: (result: symbol) => unknown; private resolveUntil: (value: symbol | PromiseLike<symbol>) => void;
private rejectUntil: (reason: unknown) => unknown; private rejectUntil: (reason?: unknown) => void;
public constructor( public constructor(
private _canFetch: boolean, private _canFetch: boolean,
private readonly logger: Logger private readonly logger: Logger
) { ) {
[this.until, this.resolveUntil, this.rejectUntil] = ({ promise: this.until, resolve: this.resolveUntil, reject: this.rejectUntil } = Promise.withResolvers<symbol>());
createPromise<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 { public get canFetch(): boolean {
return this._canFetch; return this._canFetch;
} }
/** /**
* Allow or disallow fetching. The changes only take effect if not resetting. * Allow or disallow fetching. The changes only take effect if not resetting.
* When called during a reset, its effect is deferred until the reset is finished. * When called during a reset, its effect is deferred until the reset is finished.
* *
* @param canFetch Whether fetching is enabled * @param canFetch Whether fetching is enabled
*/ */
public set canFetch(canFetch: boolean) { public set canFetch(canFetch: boolean) {
this._canFetch = canFetch; this._canFetch = canFetch;
if (!this.isResetting) { if (!this.isResetting) {
const previousResolve = this.resolveUntil; const previousResolve = this.resolveUntil;
[this.until, this.resolveUntil, this.rejectUntil] = ({ promise: this.until, resolve: this.resolveUntil, reject: this.rejectUntil } = Promise.withResolvers<symbol>());
createPromise<symbol>();
previousResolve(FetchController.UNTIL_RESOLUTION); previousResolve(FetchController.UNTIL_RESOLUTION);
} }
} }
@ -59,9 +56,9 @@ export class FetchController {
} }
/** /**
* Starts a reset, causing all ongoing and future fetches to be rejected * Starts a reset, causing all ongoing and future fetches to be rejected
* with a SyncResetError until finishReset is called. * with a SyncResetError until finishReset is called.
*/ */
public startReset(): void { public startReset(): void {
this.isResetting = true; this.isResetting = true;
this.rejectUntil(new SyncResetError()); this.rejectUntil(new SyncResetError());
@ -72,32 +69,32 @@ export class FetchController {
} }
/** /**
* Finishes a reset, allowing fetches to proceed or wait again depending on * Finishes a reset, allowing fetches to proceed or wait again depending on
* the current sync settings. * the current sync settings.
*/ */
public finishReset(): void { public finishReset(): void {
if (!this.isResetting) { if (!this.isResetting) {
return; return;
} }
this.isResetting = false; this.isResetting = false;
[this.until, this.resolveUntil, this.rejectUntil] = createPromise(); ({ promise: this.until, resolve: this.resolveUntil, reject: this.rejectUntil } = Promise.withResolvers<symbol>());
} }
/** /**
* *
* |------------------|---------------|-----------------------------------------------------| * |------------------|---------------|-----------------------------------------------------|
* | | Sync enabled | Sync disabled | * | | Sync enabled | Sync disabled |
* |------------------|-------------- |-----------------------------------------------------| * |------------------|-------------- |-----------------------------------------------------|
* | During reset | Rejects with SyncResetError without sending request | * | During reset | Rejects with SyncResetError without sending request |
* |------------------|-------------- |-----------------------------------------------------| * |------------------|-------------- |-----------------------------------------------------|
* | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch | * | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch |
* |------------------|---------------|-----------------------------------------------------| * |------------------|---------------|-----------------------------------------------------|
* *
* @param logger for errors * @param logger for errors
* @param fetch to wrap * @param fetch to wrap
* @returns a wrapped fetch implementation affected by the FetchController state * @returns a wrapped fetch implementation affected by the FetchController state
*/ */
public getControlledFetchImplementation( public getControlledFetchImplementation(
logger: Logger, logger: Logger,
fetch: typeof globalThis.fetch = globalThis.fetch fetch: typeof globalThis.fetch = globalThis.fetch

View file

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

View file

@ -2,13 +2,14 @@ import type {
DocumentId, DocumentId,
RelativePath, RelativePath,
VaultUpdateId VaultUpdateId
} from "../persistence/database"; } from "../sync-operations/types";
import type { Logger } from "../tracing/logger"; import type { Logger } from "../tracing/logger";
import type { Settings } from "../persistence/settings"; import type { Settings } from "../persistence/settings";
import type { FetchController } from "./fetch-controller"; import type { FetchController } from "./fetch-controller";
import { sleep } from "../utils/sleep"; import { sleep } from "../utils/sleep";
import { SyncResetError } from "./sync-reset-error"; import { SyncResetError } from "../errors/sync-reset-error";
import { HttpClientError } from "../errors/http-client-error";
import type { SerializedError } from "./types/SerializedError"; import type { SerializedError } from "./types/SerializedError";
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
@ -66,19 +67,15 @@ export class SyncService {
} }
public async create({ public async create({
documentId,
relativePath, relativePath,
contentBytes contentBytes
}: { }: {
documentId?: DocumentId;
relativePath: RelativePath; relativePath: RelativePath;
contentBytes: Uint8Array; contentBytes: Uint8Array;
}): Promise<DocumentVersionWithoutContent> { }): Promise<DocumentUpdateResponse> {
return this.retryForever(async () => { return this.retryForever(async () => {
const formData = new FormData(); const formData = new FormData();
if (documentId !== undefined) {
formData.append("document_id", documentId);
}
formData.append("relative_path", relativePath); formData.append("relative_path", relativePath);
formData.append( formData.append(
"content", "content",
@ -86,7 +83,7 @@ export class SyncService {
); );
this.logger.debug( this.logger.debug(
`Creating document with id ${documentId} and relative path ${relativePath}` `Creating document with relative path ${relativePath}`
); );
const response = await this.client(this.getUrl("/documents"), { const response = await this.client(this.getUrl("/documents"), {
@ -103,8 +100,8 @@ export class SyncService {
); );
} }
const result: DocumentVersionWithoutContent = const result: DocumentUpdateResponse =
(await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
this.logger.debug(`Created document ${JSON.stringify(result)}`); this.logger.debug(`Created document ${JSON.stringify(result)}`);
@ -143,13 +140,7 @@ export class SyncService {
} }
); );
if (!response.ok) { await SyncService.throwIfNotOk(response, "update document");
throw new Error(
`Failed to update document: ${await SyncService.errorFromResponse(
response
)}`
);
}
const result: DocumentUpdateResponse = const result: DocumentUpdateResponse =
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
@ -196,13 +187,7 @@ export class SyncService {
} }
); );
if (!response.ok) { await SyncService.throwIfNotOk(response, "update document");
throw new Error(
`Failed to update document: ${await SyncService.errorFromResponse(
response
)}`
);
}
const result: DocumentUpdateResponse = const result: DocumentUpdateResponse =
(await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
@ -417,8 +402,10 @@ export class SyncService {
try { try {
return await fn(); return await fn();
} catch (e) { } catch (e) {
// We must not retry errors coming from reset if (
if (e instanceof SyncResetError) { e instanceof SyncResetError ||
e instanceof HttpClientError
) {
throw e; throw e;
} }
@ -431,4 +418,16 @@ export class SyncService {
} }
} }
} }
private static async throwIfNotOk(
response: Response,
operation: string
): Promise<void> {
if (response.ok) return;
const message = `Failed to ${operation}: ${await SyncService.errorFromResponse(response)}`;
if (response.status >= 400 && response.status < 500) {
throw new HttpClientError(response.status, message);
}
throw new Error(message);
}
} }

View file

@ -1,3 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DeleteDocumentVersion = Record<string, never>; export interface DeleteDocumentVersion {
relativePath: string;
}

View 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.
import type { VaultInfo } from "./VaultInfo";
/**
* Response to listing vaults accessible to the authenticated user.
*/
export interface ListVaultsResponse { vaults: VaultInfo[], hasMore: boolean, userName: string, }

View file

@ -0,0 +1,6 @@
// 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, }

View file

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

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