diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts deleted file mode 100644 index 998e47ec..00000000 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { describe, it } from "node:test"; -import type { - Database, - DocumentRecord, - RelativePath -} from "../persistence/database"; -import { FileOperations } from "./file-operations"; -import { Logger } from "../tracing/logger"; -import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly"; -import type { FileSystemOperations } from "./filesystem-operations"; -import type { TextWithCursors } from "reconcile-text"; -import type { ServerConfig, ServerConfigData } from "../services/server-config"; - -class MockServerConfig implements Pick { - public async getConfig(): Promise { - return { - mergeableFileExtensions: ["md", "txt"], - supportedApiVersion: 1, - isAuthenticated: true - }; - } -} - -class MockDatabase implements Partial { - public getLatestDocumentByRelativePath( - _find: RelativePath - ): DocumentRecord | undefined { - // no-op - return undefined; - } - - public move( - _oldRelativePath: RelativePath, - _newRelativePath: RelativePath - ): void { - // no-op - } -} - -class FakeFileSystemOperations implements FileSystemOperations { - public readonly names = new Set(); - - public async listFilesRecursively( - _root: RelativePath | undefined - ): Promise { - return ["file.md"]; - } - public async read(_path: RelativePath): Promise { - throw new Error("Method not implemented."); - } - public async write( - path: RelativePath, - _content: Uint8Array - ): Promise { - this.names.add(path); - } - public async atomicUpdateText( - _path: RelativePath, - _updater: (current: TextWithCursors) => TextWithCursors - ): Promise { - throw new Error("Method not implemented."); - } - public async getFileSize(_path: RelativePath): Promise { - throw new Error("Method not implemented."); - } - public async getModificationTime(_path: RelativePath): Promise { - throw new Error("Method not implemented."); - } - public async exists(path: RelativePath): Promise { - return this.names.has(path); - } - public async createDirectory(_path: RelativePath): Promise { - // this is called but irrelevant for this mock - } - public async delete(_path: RelativePath): Promise { - throw new Error("Method not implemented."); - } - public async rename( - oldPath: RelativePath, - newPath: RelativePath - ): Promise { - this.names.delete(oldPath); - this.names.add(newPath); - } -} - -describe("File operations", () => { - it("should deconflict renames", async () => { - const fileSystemOperations = new FakeFileSystemOperations(); - const fileOperations = new FileOperations( - new Logger(), - new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - fileSystemOperations, - new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - ); - - await fileOperations.create("a", new Uint8Array()); - assertSetContainsExactly(fileSystemOperations.names, "a"); - await fileOperations.move("a", "b"); - assertSetContainsExactly(fileSystemOperations.names, "b"); - - await fileOperations.create("c", new Uint8Array()); - assertSetContainsExactly(fileSystemOperations.names, "b", "c"); - - await fileOperations.move("c", "b"); - assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)"); - - await fileOperations.create("c", new Uint8Array()); - await fileOperations.move("c", "b"); - assertSetContainsExactly( - fileSystemOperations.names, - "b", - "b (1)", - "b (2)" - ); - }); - - it("should deconflict renames with file extension", async () => { - const fileSystemOperations = new FakeFileSystemOperations(); - const fileOperations = new FileOperations( - new Logger(), - new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - fileSystemOperations, - new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - ); - - await fileOperations.create("b.md", new Uint8Array()); - await fileOperations.create("c.md", new Uint8Array()); - await fileOperations.move("c.md", "b.md"); - assertSetContainsExactly( - fileSystemOperations.names, - "b.md", - "b (1).md" - ); - - await fileOperations.create("d.md", new Uint8Array()); - await fileOperations.move("d.md", "b.md"); - assertSetContainsExactly( - fileSystemOperations.names, - "b.md", - "b (1).md", - "b (2).md" - ); - - await fileOperations.create("file-23.md", new Uint8Array()); - await fileOperations.create("file-23 (1).md", new Uint8Array()); - await fileOperations.move("file-23.md", "file-23 (1).md"); - assertSetContainsExactly( - fileSystemOperations.names, - "b.md", - "b (1).md", - "b (2).md", - "file-23 (1).md", - "file-23 (2).md" - ); - }); - - it("should deconflict renames with paths", async () => { - const fileSystemOperations = new FakeFileSystemOperations(); - const fileOperations = new FileOperations( - new Logger(), - new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - fileSystemOperations, - new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - ); - - await fileOperations.create("a/b.c/d", new Uint8Array()); - await fileOperations.create("a/b.c/e", new Uint8Array()); - await fileOperations.move("a/b.c/d", "a/b.c/e"); - assertSetContainsExactly( - fileSystemOperations.names, - "a/b.c/e", - "a/b.c/e (1)" - ); - }); - - it("should continue deconfliction from existing number in filename", async () => { - const fileSystemOperations = new FakeFileSystemOperations(); - const fileOperations = new FileOperations( - new Logger(), - new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - fileSystemOperations, - new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - ); - - await fileOperations.create("document (5).md", new Uint8Array()); - await fileOperations.create("other.md", new Uint8Array()); - - await fileOperations.move("other.md", "document (5).md"); - assertSetContainsExactly( - fileSystemOperations.names, - "document (5).md", - "document (6).md" - ); - - await fileOperations.create("another.md", new Uint8Array()); - await fileOperations.move("another.md", "document (5).md"); - assertSetContainsExactly( - fileSystemOperations.names, - "document (5).md", - "document (6).md", - "document (7).md" - ); - }); - - it("should handle dotfiles correctly", async () => { - const fileSystemOperations = new FakeFileSystemOperations(); - const fileOperations = new FileOperations( - new Logger(), - new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - fileSystemOperations, - new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - ); - - await fileOperations.create(".gitignore", new Uint8Array()); - await fileOperations.create("temp", new Uint8Array()); - await fileOperations.move("temp", ".gitignore"); - assertSetContainsExactly( - fileSystemOperations.names, - ".gitignore", - ".gitignore (1)" - ); - - await fileOperations.create(".config.json", new Uint8Array()); - await fileOperations.create("temp2", new Uint8Array()); - await fileOperations.move("temp2", ".config.json"); - assertSetContainsExactly( - fileSystemOperations.names, - ".gitignore", - ".gitignore (1)", - ".config.json", - ".config (1).json" - ); - }); -}); diff --git a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts deleted file mode 100644 index 904bf805..00000000 --- a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { RelativePath } from "../persistence/database"; -import type { FileSystemOperations } from "./filesystem-operations"; -import type { Logger } from "../tracing/logger"; -import { Locks } from "../utils/data-structures/locks"; -import { FileNotFoundError } from "./file-not-found-error"; -import type { TextWithCursors } from "reconcile-text"; - -/** - * Decorates `FileSystemOperations` to replace errors with `FileNotFoundError` - * if the accessed file doesn't exist. It also ensures that there's at most a - * single request in-flight for any one file through the use of locks. - */ -export class SafeFileSystemOperations implements FileSystemOperations { - private readonly locks: Locks; - - public constructor( - private readonly fs: FileSystemOperations, - private readonly logger: Logger - ) { - this.locks = new Locks(logger); - } - - public async listFilesRecursively( - root: RelativePath | undefined - ): Promise { - this.logger.debug("Listing all files"); - const result = await this.fs.listFilesRecursively(root); - this.logger.debug(`Listed ${result.length} files`); - return result; - } - - public async read(path: RelativePath): Promise { - this.logger.debug(`Reading file '${path}'`); - return this.safeOperation( - path, - async () => - this.locks.withLock(path, async () => this.fs.read(path)), - "read" - ); - } - - public async write(path: RelativePath, content: Uint8Array): Promise { - this.logger.debug(`Writing to file '${path}'`); - return this.locks.withLock(path, async () => - this.fs.write(path, content) - ); - } - - public async atomicUpdateText( - path: RelativePath, - updater: (current: TextWithCursors) => TextWithCursors - ): Promise { - this.logger.debug(`Atomically updating file '${path}'`); - return this.safeOperation( - path, - async () => - this.locks.withLock(path, async () => - this.fs.atomicUpdateText(path, updater) - ), - "atomicUpdateText" - ); - } - - public async getFileSize(path: RelativePath): Promise { - // Logging this would be too noisy - return this.safeOperation( - path, - async () => - this.locks.withLock(path, async () => - this.fs.getFileSize(path) - ), - "getFileSize" - ); - } - - public async exists( - path: RelativePath, - skipLock = false - ): Promise { - this.logger.debug(`Checking if file '${path}' exists`); - if (skipLock) { - return this.fs.exists(path); - } else { - return this.locks.withLock(path, async () => this.fs.exists(path)); - } - } - - public async createDirectory(path: RelativePath): Promise { - this.logger.debug(`Creating directory '${path}'`); - return this.locks.withLock(path, async () => - this.fs.createDirectory(path) - ); - } - - public async delete(path: RelativePath): Promise { - this.logger.debug(`Deleting file '${path}'`); - return this.locks.withLock(path, async () => this.fs.delete(path)); - } - - public async rename( - oldPath: RelativePath, - newPath: RelativePath, - skipLock = false - ): Promise { - this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`); - return this.safeOperation( - oldPath, - async () => { - if (skipLock) { - return this.fs.rename(oldPath, newPath); - } else { - return this.locks.withLock([oldPath, newPath], async () => - this.fs.rename(oldPath, newPath) - ); - } - }, - "rename" - ); - } - - public tryLock(path: RelativePath): boolean { - return this.locks.tryLock(path); - } - - public async waitForLock(path: RelativePath): Promise { - return this.locks.waitForLock(path); - } - - public unlock(path: RelativePath): void { - this.locks.unlock(path); - } - - public reset(): void { - this.locks.reset(); - } - - /** - * Decorate an operation to ensure that the file exists before running it. - * If the operation fails, it will check if the file still exists and throw - * a FileNotFoundError if it doesn't. - */ - private async safeOperation( - path: RelativePath, - operation: () => Promise, - operationName: string - ): Promise { - if (!(await this.fs.exists(path))) { - throw new FileNotFoundError( - `File not found before trying to ${operationName}`, - path - ); - } - - try { - return await operation(); - } catch (error) { - // Without locking the file, this isn't atomic, however, it's good enough in practice. - // This will only break if the file exists, gets deleted and then immediately - // recreated while `operation` is running. - if (await this.fs.exists(path)) { - throw error; - } else { - throw new FileNotFoundError( - `File not found when trying to ${operationName}`, - path - ); - } - } - } -} diff --git a/frontend/sync-client/src/services/websocket-manager.test.ts b/frontend/sync-client/src/services/websocket-manager.test.ts index fef901e7..969a02eb 100644 --- a/frontend/sync-client/src/services/websocket-manager.test.ts +++ b/frontend/sync-client/src/services/websocket-manager.test.ts @@ -134,9 +134,21 @@ describe("WebSocketManager", () => { const mockWs = (manager as unknown as { webSocket: MockWebSocket }) .webSocket; - mockWs.simulateMessage({ type: "vaultUpdate", updates: [] }); - mockWs.simulateMessage({ type: "vaultUpdate", updates: [] }); - mockWs.simulateMessage({ type: "vaultUpdate", updates: [] }); + mockWs.simulateMessage({ + type: "vaultEvents", + headEventId: 0, + events: [] + }); + mockWs.simulateMessage({ + type: "vaultEvents", + headEventId: 0, + events: [] + }); + mockWs.simulateMessage({ + type: "vaultEvents", + headEventId: 0, + events: [] + }); await new Promise((resolve) => setTimeout(resolve, 100)); @@ -218,6 +230,31 @@ describe("WebSocketManager", () => { assert.ok(true); }); + it("uses a secure URL, preserves the configured prefix, and encodes the vault", async () => { + mockSettings = { + getSettings: () => ({ + remoteUri: "https://example.com/sync/", + vaultName: "team/a ?", + webSocketRetryIntervalMs: 1000 + }) + } as unknown as Settings; + const manager = new WebSocketManager( + deviceId, + mockLogger, + mockSettings, + MockWebSocket as unknown as typeof WebSocket + ); + + manager.start(); + const mockWs = (manager as unknown as { webSocket: MockWebSocket }) + .webSocket; + assert.strictEqual( + String(mockWs.url), + "wss://example.com/sync/vaults/team%2Fa%20%3F/ws" + ); + await manager.stop(); + }); + it("clears old handlers on reconnection", async () => { const manager = new WebSocketManager( deviceId, @@ -278,7 +315,11 @@ describe("WebSocketManager", () => { const mockWs = (manager as unknown as { webSocket: MockWebSocket }) .webSocket; - mockWs.simulateMessage({ type: "vaultUpdate", updates: [] }); + mockWs.simulateMessage({ + type: "vaultEvents", + headEventId: 0, + events: [] + }); await new Promise((resolve) => setTimeout(resolve, 10)); diff --git a/frontend/sync-client/src/utils/package-version.test.ts b/frontend/sync-client/src/utils/package-version.test.ts new file mode 100644 index 00000000..64520e22 --- /dev/null +++ b/frontend/sync-client/src/utils/package-version.test.ts @@ -0,0 +1,9 @@ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import { packageVersion } from "./package-version"; + +it("supports direct source imports without webpack globals", async () => { + assert.strictEqual(packageVersion, "development"); + const { SyncClient } = await import("../sync-client"); + assert.strictEqual(typeof SyncClient.create, "function"); +});