From c869f0a8804fa1e5e10ab321ffd95c48c3b48a3e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 27 Sep 2026 09:04:05 +0100 Subject: [PATCH] .... --- frontend/sync-client/src/errors/errors.ts | 18 +-- .../src/file-operations/file-operations.ts | 35 ++---- .../src/file-operations/file-paths.ts | 36 +++--- .../sync-client/src/persistence/database.ts | 36 +----- .../sync-client/src/services/server-config.ts | 23 ++-- .../sync-client/src/services/sync-service.ts | 81 ++++++------- .../src/services/websocket-manager.test.ts | 14 +-- .../src/services/websocket-manager.ts | 34 ++---- frontend/sync-client/src/sync-client.ts | 28 +++-- .../src/sync-operations/content.ts | 5 +- .../sync-operations/cursor-tracker.test.ts | 87 ++++++-------- .../src/sync-operations/cursor-tracker.ts | 112 +++++++----------- .../src/sync-operations/file-manifest.ts | 12 +- .../sync-operations/outgoing-cursor.test.ts | 47 ++++---- .../sync-client/src/sync-operations/scan.ts | 46 ++++--- .../src/sync-operations/syncer.test.ts | 16 +-- .../sync-client/src/sync-operations/syncer.ts | 67 +++++------ .../sync-client/src/utils/await-all.test.ts | 18 +++ frontend/sync-client/src/utils/await-all.ts | 27 ++--- .../debugging/slow-web-socket-factory.ts | 46 +++---- .../sync-client/src/utils/decode-text.test.ts | 20 ++++ frontend/sync-client/src/utils/decode-text.ts | 6 + frontend/sync-client/src/utils/is-binary.ts | 10 +- .../src/utils/portable-path.test.ts | 46 ++++++- .../sync-client/src/utils/portable-path.ts | 79 +++++------- frontend/sync-client/src/utils/rate-limit.ts | 58 +++------ .../test-support/engine-regressions.test.ts | 4 +- frontend/test-support/event-delivery.test.ts | 4 +- frontend/test-support/polling.test.ts | 1 - frontend/test-support/recovery.test.ts | 20 ++-- frontend/test-support/sync-audit.test.ts | 4 +- frontend/test-support/sync-fixture.ts | 1 - 32 files changed, 462 insertions(+), 579 deletions(-) create mode 100644 frontend/sync-client/src/utils/decode-text.test.ts create mode 100644 frontend/sync-client/src/utils/decode-text.ts diff --git a/frontend/sync-client/src/errors/errors.ts b/frontend/sync-client/src/errors/errors.ts index 3844ce10..1169453d 100644 --- a/frontend/sync-client/src/errors/errors.ts +++ b/frontend/sync-client/src/errors/errors.ts @@ -1,5 +1,3 @@ -import type { RelativePath } from "../persistence/database"; - export class SyncResetError extends Error { public constructor() { super("SyncClient has been reset, cleaning up"); @@ -21,19 +19,9 @@ export class AuthenticationError extends Error { } } -export class FileNotFoundError extends Error { - public constructor( - message: string, - public readonly filePath: RelativePath - ) { - super(message); - this.name = "FileNotFoundError"; - } -} +export class PermanentSyncError extends Error {} -export class PermanentSyncError extends Error { } - -export class LocalChangesDuringReconciliation extends Error { } +export class LocalChangesDuringReconciliation extends Error {} /** The server rejected a checkpoint from a discarded/restored history. */ -export class ServerHistoryChangedError extends Error { } +export class ServerHistoryChangedError extends Error {} diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index ca7ff8a1..a3c36582 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -17,16 +17,14 @@ import { mergeContent } from "../sync-operations/content"; import { hash } from "../utils/hash"; import { isBinary } from "../utils/is-binary"; import { isFileTypeMergeable } from "../utils/is-file-type-mergeable"; +import { decodeText } from "../utils/decode-text"; import type { ServerConfig } from "../services/server-config"; import { applyLocalChange, unappliedChanges, type LocalChange } from "../sync-operations/local-changes"; -import { - FileNotFoundError, - LocalChangesDuringReconciliation -} from "../errors/errors"; +import { LocalChangesDuringReconciliation } from "../errors/errors"; export interface FileWrite { expected?: HashedSnapshot; @@ -62,15 +60,6 @@ export class FileOperations { this.paths = new FilePaths(fs, isProtectedFile); } - public async read(path: RelativePath): Promise { - const snapshot = await this.fs.read(path); - - if (!snapshot) - throw new FileNotFoundError(`File not found: ${path}`, path); - - return snapshot.content; - } - public async snapshot( path: RelativePath ): Promise { @@ -84,10 +73,10 @@ export class FileOperations { ); } - public async captureChange( + public captureChange( change: LocalChange, previous: readonly LocalChange[] - ): Promise { + ): void { const state = structuredClone(this.applying ?? this.database.state); if (state.lastSeenUpdateId === undefined) return; @@ -249,27 +238,23 @@ export class FileOperations { isBinary(value.content) ) ) { - const decoder = new TextDecoder("utf-8", { - fatal: true, - ignoreBOM: true - }); - const parent = decoder.decode(base.content); - const remote = decoder.decode(replacement.content); + const parent = decodeText(base.content); + const remote = decodeText(replacement.content); const text = await this.fs.atomicUpdateText(path, (latest) => reconcile(parent, latest, remote) ); return hash(new TextEncoder().encode(text)); } const result = - current && write.expected + current && base ? await mergeContent( path, - write.expected, + base, await hashSnapshot(current), - write.replacement, + replacement, extensions ) - : write.replacement; + : replacement; await this.fs.write(path, { content: result.content, cursors: result.cursors diff --git a/frontend/sync-client/src/file-operations/file-paths.ts b/frontend/sync-client/src/file-operations/file-paths.ts index e1ce6bfa..ac71411b 100644 --- a/frontend/sync-client/src/file-operations/file-paths.ts +++ b/frontend/sync-client/src/file-operations/file-paths.ts @@ -1,14 +1,21 @@ import { v4 as uuid } from "uuid"; import type { FileSystemOperations } from "./filesystem-operations"; -import type { DocumentId, EngineState, RelativePath } from "../persistence/database"; +import type { + DocumentId, + EngineState, + RelativePath +} from "../persistence/database"; import { allocatePortablePath, isInternalPath } from "../utils/portable-path"; /** Resolve occupied rename destinations without discarding another file. */ export class FilePaths { public constructor( private readonly fs: FileSystemOperations, - private readonly isProtectedFile: (path: RelativePath, size: number) => boolean - ) { } + private readonly isProtectedFile: ( + path: RelativePath, + size: number + ) => boolean + ) {} public async prepareDestination( next: EngineState, @@ -18,7 +25,7 @@ export class FilePaths { mutate: (action: () => Promise) => Promise ): Promise { try { - for (; ;) { + for (;;) { const obstruction = await this.obstruction(path); if (!obstruction) break; const children = @@ -29,8 +36,8 @@ export class FilePaths { for (const child of children) { const occupant = Object.keys(current.local).find( (key) => - current.documents[key]?.observedHash !== undefined && - current.local[key] === child + current.documents[key]?.observedHash !== + undefined && current.local[key] === child ); const info = await this.fs.stat(child); if ( @@ -65,7 +72,9 @@ export class FilePaths { next.local[occupant.id] = displaced; } if (obstruction.kind === "directory") - await mutate(() => this.fs.deleteDirectory(obstruction.path)); + await mutate(() => + this.fs.deleteDirectory(obstruction.path) + ); } await this.ensureParents(path); } catch (error) { @@ -113,14 +122,13 @@ export class FilePaths { id: DocumentId, path: RelativePath ): Promise { - const occupied = { ...next.local }; - delete occupied[id]; - const files = (await this.fs.listFilesRecursively()).filter( - (path) => !isInternalPath(path) + const occupied = Object.entries(next.local).flatMap( + ([other, occupiedPath]) => (other === id ? [] : [occupiedPath]) ); - for (const [i, diskPath] of files.entries()) - occupied[`disk-${i}`] = diskPath; - return allocatePortablePath(path, id, occupied); + const files = (await this.fs.listFilesRecursively()).filter( + (diskPath) => !isInternalPath(diskPath) + ); + return allocatePortablePath(path, id, [...occupied, ...files]); } private async ensureParents(path: RelativePath): Promise { diff --git a/frontend/sync-client/src/persistence/database.ts b/frontend/sync-client/src/persistence/database.ts index 6ab4d7e2..96f3de64 100644 --- a/frontend/sync-client/src/persistence/database.ts +++ b/frontend/sync-client/src/persistence/database.ts @@ -6,11 +6,6 @@ export type VaultUpdateId = number; export type DocumentId = string; export type RelativePath = string; -export interface DocumentMetadata { - parentVersionId: VaultUpdateId; - hash: string; -} - export interface ContentHead { vaultUpdateId: VaultUpdateId; contentSize: number; @@ -43,13 +38,6 @@ export interface EngineState { export type StoredDatabase = EngineState; -// Read-only view used by cursor tracking and status reporting. -export interface DocumentRecord { - documentId: DocumentId; - relativePath: RelativePath; - metadata?: DocumentMetadata; -} - export function emptyState(vaultKey: string): StoredDatabase { return { vaultKey, @@ -125,29 +113,9 @@ export class Database { return Object.keys(this.state.local).length; } - public getDocumentByDocumentId(id: DocumentId): DocumentRecord | undefined { - const relativePath = this.state.local[id]; - if (relativePath === undefined) return undefined; - const base = this.state.documents[id]?.base; - return { - documentId: id, - relativePath, - metadata: - base?.vaultUpdateId != null - ? { - parentVersionId: base.vaultUpdateId, - hash: base.hash - } - : undefined - }; - } - - public getLatestDocumentByRelativePath( - path: RelativePath - ): DocumentRecord | undefined { - const id = Object.keys(this.state.local).find( + public findDocumentId(path: RelativePath): DocumentId | undefined { + return Object.keys(this.state.local).find( (id) => this.state.local[id] === path ); - return id === undefined ? undefined : this.getDocumentByDocumentId(id); } } diff --git a/frontend/sync-client/src/services/server-config.ts b/frontend/sync-client/src/services/server-config.ts index 66daa8a6..a79014fc 100644 --- a/frontend/sync-client/src/services/server-config.ts +++ b/frontend/sync-client/src/services/server-config.ts @@ -4,24 +4,20 @@ import { ServerVersionMismatchError } from "../errors/errors"; import type { SyncService } from "./sync-service"; - -export interface ServerConfigData { - mergeableFileExtensions: string[]; - supportedApiVersion: number; - isAuthenticated: boolean; -} +import type { PingResponse } from "./types/PingResponse"; export class ServerConfig { - private config: Promise | undefined; + private config?: Promise; - public constructor(private readonly syncService: SyncService) { } + public constructor(private readonly syncService: SyncService) {} - private static validateConfig(config: ServerConfigData): void { + private static validateConfig(config: PingResponse): void { if (config.supportedApiVersion !== SUPPORTED_API_VERSION) { const shouldUpgradeClient = config.supportedApiVersion > SUPPORTED_API_VERSION; throw new ServerVersionMismatchError( - `Unsupported API version: ${config.supportedApiVersion}. Consider upgrading the ${shouldUpgradeClient ? "client" : "sync-server" + `Unsupported API version: ${config.supportedApiVersion}. Consider upgrading the ${ + shouldUpgradeClient ? "client" : "sync-server" } to ensure compatibility` ); } @@ -33,11 +29,6 @@ export class ServerConfig { } } - // warm the cache - public async initialize(): Promise { - await this.getConfig(); - } - public async checkConnection(): Promise<{ isSuccessful: boolean; message: string; @@ -59,7 +50,7 @@ export class ServerConfig { } } - public async getConfig(): Promise { + public async getConfig(): Promise { if (!this.config) { const pending = this.syncService .ping() diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index 6f856920..aa9c2986 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -44,7 +44,7 @@ export class SyncService { } public async ping(): Promise { - return this.request("/ping", undefined, true); + return this.request("/ping", { independent: true }); } public async getVaultSnapshot(): Promise { @@ -58,17 +58,19 @@ export class SyncService { public async pushFileManifest( request: PushFileManifest ): Promise { - return this.request("/file-manifest", request); + return this.request("/file-manifest", { body: request }); } public async putFileContent( id: DocumentId, request: PutFileContent ): Promise { - return this.request(`/documents/${id}`, request); + return this.request(`/documents/${id}`, { body: request }); } - public async getDocumentMetadata(id: DocumentId): Promise { + public async getDocumentMetadata( + id: DocumentId + ): Promise { return this.request(`/documents/${id}/metadata`); } @@ -81,19 +83,13 @@ export class SyncService { }): Promise { return this.request( `/documents/${documentId}/versions/${vaultUpdateId}/content`, - undefined, - false, - async (response) => new Uint8Array(await response.arrayBuffer()) + { + decode: async (response) => + new Uint8Array(await response.arrayBuffer()) + } ); } - private getHistoryHeaders(): Record { - const checkpoint = this.history.get(); - return checkpoint !== undefined - ? { "X-Vault-Link-History": checkpoint } - : {}; - } - private async recordHistory(response: Response): Promise { const checkpoint = response.headers.get("x-vault-link-history"); if (checkpoint === null || checkpoint === "") @@ -102,48 +98,44 @@ export class SyncService { if (checkpoint === previous) return; if ( previous === undefined || - Number(checkpoint.split(":")[0]) >= - Number(previous.split(":")[0]) + Number(checkpoint.split(":")[0]) >= Number(previous.split(":")[0]) ) await this.history.save(checkpoint); } - private checkHistory(response: Response): void { - if (response.headers.get("x-vault-link-history-mismatch") === "1") - throw new ServerHistoryChangedError( - "Server history changed; recovering local work" - ); - } - - private getUrl(path: string): string { - const { remoteUri, vaultName } = this.settings.getSettings(); - return `${remoteUri.replace(/\/$/u, "")}/vaults/${encodeURIComponent(vaultName)}${path}`; - } - private async request( path: string, - body?: unknown, - raw = false, - decode: (response: Response) => Promise = async (response) => - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Endpoint methods supply the generated protocol response type. - response.json() as Promise + { + body, + independent = false, + decode = async (response): Promise => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Endpoint methods supply the generated protocol response type. + response.json() as Promise + }: { + body?: unknown; + independent?: boolean; + decode?: (response: Response) => Promise; + } = {} ): Promise { - const timeout = AbortSignal.timeout( - this.settings.getSettings().requestTimeoutMs - ); - - const signal = raw + const { remoteUri, vaultName, token, requestTimeoutMs } = + this.settings.getSettings(); + const url = `${remoteUri.replace(/\/$/u, "")}/vaults/${encodeURIComponent(vaultName)}${path}`; + const checkpoint = independent ? undefined : this.history.get(); + const timeout = AbortSignal.timeout(requestTimeoutMs); + const signal = independent ? timeout : AbortSignal.any([timeout, this.session.signal]); const response = await abortable(signal, async () => - this.fetchImplementation(this.getUrl(path), { + this.fetchImplementation(url, { method: body === undefined ? "GET" : "PUT", body: body === undefined ? undefined : JSON.stringify(body), signal, headers: { - ...(raw ? {} : this.getHistoryHeaders()), - Authorization: `Bearer ${this.settings.getSettings().token}`, + ...(checkpoint === undefined + ? {} + : { "X-Vault-Link-History": checkpoint }), + Authorization: `Bearer ${token}`, "Device-Id": this.deviceId, "Content-Type": "application/json" } @@ -151,7 +143,10 @@ export class SyncService { ); signal.throwIfAborted(); - this.checkHistory(response); + if (response.headers.get("x-vault-link-history-mismatch") === "1") + throw new ServerHistoryChangedError( + "Server history changed; recovering local work" + ); if (!response.ok) { this.throwForErrorResponse( @@ -161,7 +156,7 @@ export class SyncService { } // Finish metadata saves even if the transport is paused meanwhile. - if (!raw) await this.recordHistory(response); + if (!independent) await this.recordHistory(response); return abortable(signal, async () => decode(response)); } diff --git a/frontend/sync-client/src/services/websocket-manager.test.ts b/frontend/sync-client/src/services/websocket-manager.test.ts index 37aa2bbf..74cf2848 100644 --- a/frontend/sync-client/src/services/websocket-manager.test.ts +++ b/frontend/sync-client/src/services/websocket-manager.test.ts @@ -131,7 +131,7 @@ describe("WebSocketManager", () => { await new Promise((resolve) => setTimeout(resolve, 50)); const { outstandingPromises } = manager as unknown as { - outstandingPromises: Promise[]; + outstandingPromises: Set>; }; const mockWs = (manager as unknown as { webSocket: MockWebSocket }) .webSocket; @@ -148,7 +148,7 @@ describe("WebSocketManager", () => { await new Promise((resolve) => setTimeout(resolve, 100)); - assert.strictEqual(outstandingPromises.length, 0); + assert.strictEqual(outstandingPromises.size, 0); await manager.stop(); }); @@ -167,7 +167,7 @@ describe("WebSocketManager", () => { await new Promise((resolve) => setTimeout(resolve, 50)); const { outstandingPromises } = manager as unknown as { - outstandingPromises: Promise[]; + outstandingPromises: Set>; }; const mockWs = (manager as unknown as { webSocket: MockWebSocket }) .webSocket; @@ -178,7 +178,7 @@ describe("WebSocketManager", () => { }); await new Promise((resolve) => setTimeout(resolve, 100)); - assert.strictEqual(outstandingPromises.length, 0); + assert.strictEqual(outstandingPromises.size, 0); await manager.stop(); }); @@ -321,16 +321,16 @@ describe("WebSocketManager", () => { await new Promise((resolve) => setTimeout(resolve, 10)); const { outstandingPromises } = manager as unknown as { - outstandingPromises: Promise[]; + outstandingPromises: Set>; }; - assert.ok(outstandingPromises.length > 0); + assert.ok(outstandingPromises.size > 0); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion resolveListener!(); await new Promise((resolve) => setTimeout(resolve, 50)); - assert.strictEqual(outstandingPromises.length, 0); + assert.strictEqual(outstandingPromises.size, 0); await manager.stop(); }); }); diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index f82a2541..6fd328ae 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -5,7 +5,6 @@ import type { WebSocketClientMessage } from "./types/WebSocketClientMessage"; import type { CursorPositionFromClient } from "./types/CursorPositionFromClient"; import type { ClientCursors } from "./types/ClientCursors"; import { WEBSOCKET_RECEIVE_TIMEOUT_MS } from "../consts"; -import { removeFromArray } from "../utils/remove-from-array"; import { EventListeners } from "../utils/data-structures/event-listeners"; import { awaitAll } from "../utils/await-all"; @@ -26,7 +25,7 @@ export class WebSocketManager { private receiveTimeoutId: ReturnType | undefined; private reconnectTimeoutId: ReturnType | undefined; - private readonly outstandingPromises: Promise[] = []; + private readonly outstandingPromises = new Set>(); private webSocket: WebSocket | undefined; private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket; @@ -39,16 +38,12 @@ export class WebSocketManager { ) { if (webSocketImplementation) { this.webSocketFactoryImplementation = webSocketImplementation; + } else if (typeof globalThis.WebSocket !== "undefined") { + this.webSocketFactoryImplementation = globalThis.WebSocket; } else { - if ( - typeof globalThis !== "undefined" && - typeof globalThis.WebSocket === "undefined" - ) { - // eslint-disable-next-line - this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js - } else { - this.webSocketFactoryImplementation = WebSocket; - } + this.webSocketFactoryImplementation = + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-type-assertion -- Node versions without WebSocket use ws. + require("ws") as typeof globalThis.WebSocket; } } @@ -75,7 +70,7 @@ export class WebSocketManager { } public async waitUntilFinished(): Promise { - await awaitAll(this.outstandingPromises); + await awaitAll([...this.outstandingPromises]); } public updateLocalCursors(cursorPositions: CursorPositionFromClient): void { @@ -182,12 +177,12 @@ export class WebSocketManager { this.webSocket = socket; this.expectResponse(socket); - this.webSocket.onerror = (): void => { + socket.onerror = (): void => { this.logger.warn("WebSocket transport error"); this.reconnect(socket); }; - this.webSocket.onopen = (): void => { + socket.onopen = (): void => { if (this.webSocket !== socket || this.isStopped) return; try { const handshake: WebSocketClientMessage = { @@ -209,7 +204,7 @@ export class WebSocketManager { } }; - this.webSocket.onmessage = (event): void => { + socket.onmessage = (event): void => { if (this.webSocket !== socket || this.isStopped) return; try { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion @@ -229,13 +224,10 @@ export class WebSocketManager { ); }) .finally(() => { - removeFromArray( - this.outstandingPromises, - messageHandlingPromise - ); + this.outstandingPromises.delete(messageHandlingPromise); }); - this.outstandingPromises.push(messageHandlingPromise); + this.outstandingPromises.add(messageHandlingPromise); } catch (error) { this.logger.error( `Error parsing WebSocket message: ${String(error)}` @@ -243,7 +235,7 @@ export class WebSocketManager { } }; - this.webSocket.onclose = (event): void => { + socket.onclose = (event): void => { this.logger.warn( `WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})` ); diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index abf79eeb..ded8311f 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -206,7 +206,7 @@ export class SyncClient { }) } ); - const cursors = new CursorTracker(database, websocket, files, notifier); + const cursors = new CursorTracker(database, websocket, fs, notifier); return new SyncClient( logger, history, @@ -274,13 +274,17 @@ export class SyncClient { ); this.started = true; this.configureTelemetry(); - if (this.settings.getSettings().isSyncEnabled) await this.resume(); + await this.resume(); }); } private async resume(): Promise { - if (this.destroyed) return; - if (!this.settings.getSettings().isSyncEnabled) return; + if ( + !this.started || + this.destroyed || + !this.settings.getSettings().isSyncEnabled + ) + return; await this.database.bindVault(vaultKey(this.settings.getSettings())); this.service.resume(); this.syncer.start(); @@ -302,8 +306,7 @@ export class SyncClient { this.serverConfig.reset(); this.cursorTracker.reset(); await this.syncer.retryRejectedRequests(); - if (this.started && this.settings.getSettings().isSyncEnabled) - await this.resume(); + await this.resume(); }); } @@ -338,17 +341,16 @@ export class SyncClient { this.settings.getSettings().diffCacheSizeMB * 1024 * 1024 ); this.configureTelemetry(); - if (this.started && this.settings.getSettings().isSyncEnabled) - await this.resume().catch((resumeError: unknown) => { - this.logger.error( - `Settings recovery could not resume sync: ${String(resumeError)}` - ); - }); + await this.resume().catch((resumeError: unknown) => { + this.logger.error( + `Settings recovery could not resume sync: ${String(resumeError)}` + ); + }); throw error; } this.serverConfig.reset(); this.configureTelemetry(); - if (this.started && next.isSyncEnabled) await this.resume(); + await this.resume(); return [this.settings.getSettings(), old] as const; }); await this.settings.onSettingsChanged.triggerAsync(...notification); diff --git a/frontend/sync-client/src/sync-operations/content.ts b/frontend/sync-client/src/sync-operations/content.ts index 308068f5..a44e0bb0 100644 --- a/frontend/sync-client/src/sync-operations/content.ts +++ b/frontend/sync-client/src/sync-operations/content.ts @@ -3,6 +3,7 @@ import { reconcile } from "reconcile-text"; import { hashSnapshot, type HashedSnapshot } from "../snapshot"; import { isBinary } from "../utils/is-binary"; import { isFileTypeMergeable } from "../utils/is-file-type-mergeable"; +import { decodeText } from "../utils/decode-text"; export async function mergeContent( path: RelativePath, @@ -31,9 +32,7 @@ export async function mergeContent( if (!isFileTypeMergeable(path, extensions) || inputs.some(isBinary)) return remote; - const [parentText, localText, remoteText] = inputs.map((bytes) => - new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes) - ); + const [parentText, localText, remoteText] = inputs.map(decodeText); const merged = reconcile( parentText, diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts index a864bc99..18acb14a 100644 --- a/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts @@ -1,29 +1,35 @@ /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ import assert from "node:assert/strict"; import { it } from "node:test"; -import type { +import { Database, - RelativePath, - VaultUpdateId + emptyState, + type RelativePath, + type VaultUpdateId } from "../persistence/database"; -import type { FileOperations } from "../file-operations/file-operations"; +import type { FileSystemOperations } from "../file-operations/filesystem-operations"; import type { WebSocketManager } from "../services/websocket-manager"; import type { ClientCursors } from "../services/types/ClientCursors"; import { EventListeners } from "../utils/data-structures/event-listeners"; import { hash } from "../utils/hash"; import { CursorTracker } from "./cursor-tracker"; +function createDatabase(digest: string): Database { + const state = emptyState("test"); + state.local.document = "note.md"; + state.documents.document = { base: { vaultUpdateId: 1, hash: digest } }; + return new Database( + state, + async () => undefined, + "test", + async () => undefined + ); +} + it("keeps the last usable cursor while retaining only the latest future update", async (context) => { const content = new TextEncoder().encode("current"); const contentHash = await hash(content); - let version = 1; - const database = { - getDocumentByDocumentId: () => ({ - documentId: "document", - relativePath: "note.md", - metadata: { parentVersionId: version, hash: contentHash } - }) - } as unknown as Database; + const database = createDatabase(contentHash); const websocket = { onWebSocketStatusChanged: new EventListeners(), onRemoteCursorsUpdateReceived: new EventListeners< @@ -35,7 +41,7 @@ it("keeps the last usable cursor while retaining only the latest future update", const tracker = new CursorTracker( database, websocket, - { read: async () => content } as unknown as FileOperations, + { read: async () => ({ content }) } as unknown as FileSystemOperations, notifier ); context.after(() => tracker.reset()); @@ -61,14 +67,20 @@ it("keeps the last usable cursor while retaining only the latest future update", ]); assert.equal(received[0].documentsWithCursors[0].vault_update_id, 1); } - version = 2; + database.state.documents.document.base = { + vaultUpdateId: 2, + hash: contentHash + }; await notifier.triggerAsync("note.md"); assert.equal( received[0].documentsWithCursors[0].vault_update_id, 1, "superseded future cursors must not reappear" ); - version = 3; + database.state.documents.document.base = { + vaultUpdateId: 3, + hash: contentHash + }; await notifier.triggerAsync("note.md"); assert.equal(received[0].documentsWithCursors[0].vault_update_id, 3); assert.equal(received[0].documentsWithCursors[0].relative_path, "note.md"); @@ -80,17 +92,7 @@ it("refreshes unchanged cursors on reconnect and heartbeat, and follows the docu context.mock.timers.enable({ apis: ["setInterval"] }); const content = new TextEncoder().encode("hello"); let path = "note.md"; - const record = () => ({ - documentId: "document", - relativePath: path, - metadata: { parentVersionId: 1, hash: contentHash } - }); - const contentHash = await hash(content); - const database = { - getLatestDocumentByRelativePath: (candidate: RelativePath) => - candidate === path ? record() : undefined, - getDocumentByDocumentId: () => record() - } as unknown as Database; + const database = createDatabase(await hash(content)); const sent: { documentsWithCursors: { relative_path: RelativePath; @@ -110,7 +112,7 @@ it("refreshes unchanged cursors on reconnect and heartbeat, and follows the docu const tracker = new CursorTracker( database, websocket, - { read: async () => content } as unknown as FileOperations, + { read: async () => ({ content }) } as unknown as FileSystemOperations, notifier ); const drain = async () => { @@ -133,6 +135,7 @@ it("refreshes unchanged cursors on reconnect and heartbeat, and follows the docu await drain(); assert.equal(sent.length, 3); path = "renamed.md"; + database.state.local.document = path; await notifier.triggerAsync(path); assert.equal(sent.at(-1)!.documentsWithCursors[0].relative_path, path); websocket.onWebSocketStatusChanged.trigger(false); @@ -155,13 +158,7 @@ for (const interruption of ["disconnect", "reset"] as const) { const contentHash = await hash(content); const entered = Promise.withResolvers(); const release = Promise.withResolvers(); - const database = { - getDocumentByDocumentId: () => ({ - documentId: "document", - relativePath: "note.md", - metadata: { parentVersionId: 1, hash: contentHash } - }) - } as unknown as Database; + const database = createDatabase(contentHash); const websocket = { onWebSocketStatusChanged: new EventListeners< (connected: boolean) => unknown @@ -178,9 +175,9 @@ for (const interruption of ["disconnect", "reset"] as const) { read: async () => { entered.resolve(); await release.promise; - return content; + return { content }; } - } as unknown as FileOperations, + } as unknown as FileSystemOperations, new EventListeners<(path: RelativePath) => unknown>() ); let received: ClientCursors[] = []; @@ -223,7 +220,7 @@ for (const interruption of ["disconnect", "reset"] as const) { it("a remote cursor becomes outdated if the document advances during its snapshot read", async () => { const content = new TextEncoder().encode("old"); - let metadata = { parentVersionId: 1, hash: await hash(content) }; + const database = createDatabase(await hash(content)); const entered = Promise.withResolvers(), release = Promise.withResolvers(); const websocket = { @@ -236,21 +233,15 @@ it("a remote cursor becomes outdated if the document advances during its snapsho updateLocalCursors: () => {} } as unknown as WebSocketManager; const tracker = new CursorTracker( - { - getDocumentByDocumentId: () => ({ - documentId: "document", - relativePath: "note.md", - metadata - }) - } as unknown as Database, + database, websocket, { read: async () => { entered.resolve(); await release.promise; - return content; + return { content }; } - } as unknown as FileOperations, + } as unknown as FileSystemOperations, new EventListeners<(path: RelativePath) => unknown>() ); let outdated: boolean | undefined; @@ -272,8 +263,8 @@ it("a remote cursor becomes outdated if the document advances during its snapsho } ]); await entered.promise; - metadata = { - parentVersionId: 2, + database.state.documents.document.base = { + vaultUpdateId: 2, hash: await hash(new TextEncoder().encode("new content")) }; release.resolve(); diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.ts index 086eaa20..599e83eb 100644 --- a/frontend/sync-client/src/sync-operations/cursor-tracker.ts +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.ts @@ -1,4 +1,5 @@ -import type { FileOperations } from "../file-operations/file-operations"; +/* eslint-disable @typescript-eslint/no-unnecessary-condition -- Persisted document and path maps are sparse at runtime. */ +import type { FileSystemOperations } from "../file-operations/filesystem-operations"; import type { Database, DocumentId, @@ -15,13 +16,8 @@ import { Lock } from "../utils/data-structures/locks"; import { EventListeners } from "../utils/data-structures/event-listeners"; import { CURSOR_HEARTBEAT_INTERVAL_MS } from "../consts"; -// Cursor positions are updated separately from documents. However, a given cursor position is only -// valid within a certain version of the document it belongs to. This class tracks previous and the latest -// known remote cursor positions, and for each document, tries to return the latest cursor positions that are -// not from the future. +/** Retain each device's last usable cursors and latest update awaiting content. */ export class CursorTracker { - // The returned position may be accurate, if it matches the document version, or outdated, in which case - // the client has to heuristically guess it's current position based on the local edits. public readonly onRemoteCursorsUpdated = new EventListeners< (cursors: MaybeOutdatedClientCursors[]) => unknown >(); @@ -43,19 +39,15 @@ export class CursorTracker { public constructor( private readonly database: Database, private readonly webSocketManager: WebSocketManager, - private readonly fileOperations: FileOperations, + private readonly fs: FileSystemOperations, fileChanges: EventListeners<(path: RelativePath) => unknown> ) { this.webSocketManager.onWebSocketStatusChanged.add((connected) => { - this.sessionGeneration++; - clearInterval(this.cursorTimer); - this.lastCursorInput = "[]"; - this.lastCursorMessage = "[]"; if (!connected) { - this.knownRemoteCursors = []; - this.onRemoteCursorsUpdated.trigger([]); + this.reset(); return; } + this.resetSession(); const refresh = () => { void this.publishLocalCursors(true).catch(() => { // A concurrent move/deletion can invalidate an editor view; @@ -111,9 +103,9 @@ export class CursorTracker { clientCursor.documentsWithCursors.some( (document) => document.relative_path === relativePath || - this.database.getDocumentByDocumentId( + this.database.state.local[ document.document_id - )?.relativePath === relativePath + ] === relativePath ) ) { clientCursor.upToDateness = @@ -130,9 +122,7 @@ export class CursorTracker { }); } - /// Update the local cursors for the given documents. - /// Can be called frequently as it only emits an event - /// if the state has actually changed. + /** Publish changed editor selections; heartbeats also refresh unchanged ones. */ public async sendLocalCursorsToServer( documentToCursors: Record ): Promise { @@ -150,25 +140,21 @@ export class CursorTracker { for (const [editorPath, cursors] of Object.entries( this.localCursors )) { - const id = this.localCursorDocuments[editorPath]; - const record = id - ? this.database.getDocumentByDocumentId(id) - : this.database.getLatestDocumentByRelativePath(editorPath); - - if (!record) { - continue; // Let's wait for the file to be created before sending cursors - } - this.localCursorDocuments[editorPath] = record.documentId; - const { relativePath } = record; - - if (!record.metadata) { - continue; // this is a new document, no need to sync the cursors - } + const id = + this.localCursorDocuments[editorPath] ?? + this.database.findDocumentId(editorPath); + if (id === undefined) continue; + this.localCursorDocuments[editorPath] = id; + const relativePath = this.database.state.local[id]; + const base = this.database.state.documents[id]?.base; + // Wait until the document has a path and an acknowledged version. + if (relativePath === undefined || base?.vaultUpdateId == null) + continue; documentsWithCursors.push({ relative_path: relativePath, - document_id: record.documentId, - vault_update_id: record.metadata.parentVersionId, + document_id: id, + vault_update_id: base.vaultUpdateId, cursors: cursors.map(({ start, end }) => ({ start: Math.min(start, end), end: Math.max(start, end) @@ -195,10 +181,14 @@ export class CursorTracker { } public reset(): void { - this.sessionGeneration++; - clearInterval(this.cursorTimer); + this.resetSession(); this.knownRemoteCursors = []; this.onRemoteCursorsUpdated.trigger([]); + } + + private resetSession(): void { + this.sessionGeneration++; + clearInterval(this.cursorTimer); this.lastCursorInput = "[]"; this.lastCursorMessage = "[]"; // Keep serialization intact while old reads drain. A generation fence @@ -232,9 +222,8 @@ export class CursorTracker { (document) => ({ ...document, relative_path: - this.database.getDocumentByDocumentId( - document.document_id - )?.relativePath ?? document.relative_path + this.database.state.local[document.document_id] ?? + document.relative_path }) ), isOutdated: @@ -250,8 +239,6 @@ export class CursorTracker { return result; } - // We store up-to-dateness on a per-client basis to simplify the implementation. - // An individual client won't have too many documents open at once, so this is a reasonable trade-off. private async getDocumentsUpToDateness( clientCursor: ClientCursors ): Promise { @@ -269,29 +256,19 @@ export class CursorTracker { ): Promise { if (document.vault_update_id === null) return DocumentUpToDateness.Prior; - const record = this.database.getDocumentByDocumentId( - document.document_id - ); - - if (!record) { - // the document of the cursor must be from the future + const path = this.database.state.local[document.document_id]; + if (path === undefined) return DocumentUpToDateness.Later; + const version = + this.database.state.documents[document.document_id]?.base + ?.vaultUpdateId ?? 0; + if (version < document.vault_update_id) return DocumentUpToDateness.Later; - } - - if ( - (record.metadata?.parentVersionId ?? 0) < document.vault_update_id - ) { - return DocumentUpToDateness.Later; - } else if ( - document.vault_update_id < (record.metadata?.parentVersionId ?? 0) - ) { - // the document of the cursor must be from the past + if (version > document.vault_update_id) return DocumentUpToDateness.Prior; - } return (await this.matchesLocalVersion({ ...document, - relative_path: record.relativePath + relative_path: path })) ? DocumentUpToDateness.UpToDate : DocumentUpToDateness.Prior; @@ -303,16 +280,17 @@ export class CursorTracker { relative_path, vault_update_id }: DocumentWithCursors): Promise { - const content = await this.fileOperations + const snapshot = await this.fs .read(relative_path) .catch(() => undefined); - if (!content) return false; - const digest = await hash(content); - const current = this.database.getDocumentByDocumentId(document_id); + if (!snapshot) return false; + const digest = await hash(snapshot.content); + const { local, documents } = this.database.state; + const base = documents[document_id]?.base; return ( - current?.metadata?.parentVersionId === vault_update_id && - current.relativePath === relative_path && - current.metadata.hash === digest + base?.vaultUpdateId === vault_update_id && + local[document_id] === relative_path && + base.hash === digest ); } } diff --git a/frontend/sync-client/src/sync-operations/file-manifest.ts b/frontend/sync-client/src/sync-operations/file-manifest.ts index 683c788b..faa4a29c 100644 --- a/frontend/sync-client/src/sync-operations/file-manifest.ts +++ b/frontend/sync-client/src/sync-operations/file-manifest.ts @@ -33,21 +33,17 @@ export function resolvePaths( const result: FileManifestEntries = {}; for (const id of protectedIds) if (proposed[id] !== undefined) result[id] = proposed[id]; - const occupied = { ...result }; - for (const [index, path] of reservedPaths.entries()) - if (!Object.values(result).includes(path)) - occupied[`reserved-${index}`] = path; + const occupied = [...Object.values(result), ...reservedPaths]; const ids = Object.keys(proposed).sort( (a, b) => Number(remote[b] === proposed[b]) - Number(remote[a] === proposed[a]) || (a < b ? -1 : a > b ? 1 : 0) ); - for (const id of ids.filter( - (candidate) => result[candidate] === undefined - )) { + for (const id of ids) { + if (result[id] !== undefined) continue; result[id] = allocatePortablePath(proposed[id], id, occupied); - occupied[id] = result[id]; + occupied.push(result[id]); } return result; } diff --git a/frontend/sync-client/src/sync-operations/outgoing-cursor.test.ts b/frontend/sync-client/src/sync-operations/outgoing-cursor.test.ts index f67f570f..71b7d148 100644 --- a/frontend/sync-client/src/sync-operations/outgoing-cursor.test.ts +++ b/frontend/sync-client/src/sync-operations/outgoing-cursor.test.ts @@ -3,13 +3,12 @@ import { test } from "node:test"; import { CursorTracker } from "./cursor-tracker"; import { EventListeners } from "../utils/data-structures/event-listeners"; import { hash } from "../utils/hash"; -import type { +import { Database, - DocumentId, - DocumentRecord, - RelativePath + emptyState, + type RelativePath } from "../persistence/database"; -import type { FileOperations } from "../file-operations/file-operations"; +import type { FileSystemOperations } from "../file-operations/filesystem-operations"; import type { WebSocketManager } from "../services/websocket-manager"; import type { DocumentWithCursors } from "../services/types/DocumentWithCursors"; @@ -18,11 +17,15 @@ for (const change of ["version", "identity", "path", "delete"] as const) // Equal bytes deliberately defeat a freshness check that compares only hashes. const content = new TextEncoder().encode("same bytes"); const digest = await hash(content); - let current: DocumentRecord | undefined = { - documentId: "a", - relativePath: "a.md", - metadata: { parentVersionId: 1, hash: digest } - }; + const state = emptyState("test"); + state.local.a = "a.md"; + state.documents.a = { base: { vaultUpdateId: 1, hash: digest } }; + const database = new Database( + state, + async () => undefined, + "test", + async () => undefined + ); const sent: { documentsWithCursors: DocumentWithCursors[] }[] = []; const websocket = { onWebSocketStatusChanged: new EventListeners(), @@ -33,24 +36,22 @@ for (const change of ["version", "identity", "path", "delete"] as const) }; const files = { read: async () => { - if (change === "delete") current = undefined; + const current = database.state; + if (change === "delete") delete current.local.a; else if (change === "version") - current!.metadata!.parentVersionId = 2; - else if (change === "identity") - current!.documentId = "replacement"; - else current!.relativePath = "moved.md"; - return content; + current.documents.a.base!.vaultUpdateId = 2; + else if (change === "identity") { + current.local.replacement = current.local.a; + current.documents.replacement = current.documents.a; + delete current.local.a; + } else current.local.a = "moved.md"; + return { content }; } }; const tracker = new CursorTracker( - { - getDocumentByDocumentId: (id: DocumentId) => - current?.documentId === id ? current : undefined, - getLatestDocumentByRelativePath: (path: RelativePath) => - current?.relativePath === path ? current : undefined - } as unknown as Database, + database, websocket as unknown as WebSocketManager, - files as unknown as FileOperations, + files as unknown as FileSystemOperations, new EventListeners<(path: RelativePath) => unknown>() ); await tracker.sendLocalCursorsToServer({ diff --git a/frontend/sync-client/src/sync-operations/scan.ts b/frontend/sync-client/src/sync-operations/scan.ts index 0e183951..cb8546cc 100644 --- a/frontend/sync-client/src/sync-operations/scan.ts +++ b/frontend/sync-client/src/sync-operations/scan.ts @@ -1,6 +1,10 @@ import { v4 as uuid } from "uuid"; import type { FileOperations } from "../file-operations/file-operations"; -import type { DocumentId, EngineState, RelativePath } from "../persistence/database"; +import type { + Database, + DocumentId, + RelativePath +} from "../persistence/database"; import type { VaultSnapshot } from "../services/types/VaultSnapshot"; import { EMPTY_HASH, hash } from "../utils/hash"; import { @@ -16,28 +20,27 @@ import { export type { LocalChange } from "./local-changes"; interface ScanOptions { - next: EngineState; + database: Database; guard: () => void; changes: LocalChange[]; files: FileOperations; ignored: (path: RelativePath) => boolean; oversized: (size: number) => boolean; - commit: (next: EngineState) => Promise; } /** Scans are complete or fail. Missing unmaterialized/ignored entries are not deletions. */ export async function scanLocalFiles( { - next, + database, guard, changes: queuedChanges, files, ignored, - oversized, - commit + oversized }: ScanOptions, initial?: VaultSnapshot ): Promise> { + const next = structuredClone(database.state); // Leave the prefix queued until its resulting state is saved. // A persisted change ID makes replay safe after an uncertain save. const changes = [...queuedChanges]; @@ -61,7 +64,7 @@ export async function scanLocalFiles( ); } const present = new Set(paths); - const snapshots = new Map(); + const hashes = new Map(); for (const path of paths) { if (ignored(path)) { unsyncablePaths.add(path); @@ -77,7 +80,7 @@ export async function scanLocalFiles( const snapshot = await files.fs.read(path); if (!snapshot) throw new Error(`File changed during scan: ${path}`); if (!oversized(snapshot.content.length)) { - snapshots.set(path, { hash: await hash(snapshot.content) }); + hashes.set(path, await hash(snapshot.content)); } else { unsyncablePaths.add(path); } @@ -91,20 +94,18 @@ export async function scanLocalFiles( ) ); const knownPaths = new Set(Object.values(next.local)); - const unknown = [...snapshots.keys()].filter( - (path) => !knownPaths.has(path) - ); + const unknown = [...hashes.keys()].filter((path) => !knownPaths.has(path)); for (const path of unknown) { - const snapshot = snapshots.get(path)!; + const digest = hashes.get(path)!; const matches = [...missing].filter( - (id) => next.documents[id]?.observedHash === snapshot.hash + (id) => next.documents[id]?.observedHash === digest ); const sameHash = unknown.filter( - (other) => snapshots.get(other)?.hash === snapshot.hash + (other) => hashes.get(other) === digest ); let id: DocumentId | undefined; if ( - snapshot.hash !== EMPTY_HASH && + digest !== EMPTY_HASH && matches.length === 1 && sameHash.length === 1 ) { @@ -131,24 +132,21 @@ export async function scanLocalFiles( if (next.excluded[id] === undefined) next.excluded[id] = next.fileManifest.entries[id] ?? null; } - const snapshot = snapshots.get(path); - if (snapshot) { + const digest = hashes.get(path); + if (digest !== undefined) { const doc = (next.documents[id] ??= {}); // The old history no longer contains this base. Preserve any edit // made during recovery as a new file instead of overwriting it. - if ( - doc.base?.vaultUpdateId === null && - doc.base.hash !== snapshot.hash - ) { + if (doc.base?.vaultUpdateId === null && doc.base.hash !== digest) { const recoveredId = uuid(); next.local[recoveredId] = path; - next.documents[recoveredId] = { observedHash: snapshot.hash }; + next.documents[recoveredId] = { observedHash: digest }; delete next.local[id]; - } else doc.observedHash = snapshot.hash; + } else doc.observedHash = digest; } } guard(); - await commit(next); + await database.commit(next); queuedChanges.splice(0, changes.length); guard(); return unsyncablePaths; diff --git a/frontend/sync-client/src/sync-operations/syncer.test.ts b/frontend/sync-client/src/sync-operations/syncer.test.ts index bd7afcad..3147035e 100644 --- a/frontend/sync-client/src/sync-operations/syncer.test.ts +++ b/frontend/sync-client/src/sync-operations/syncer.test.ts @@ -135,11 +135,9 @@ describe("Syncer per-file state and event delivery", () => { function fixture() { const database = { state: { - local: { document: "note.md" }, documents: { document: { - observedHash: "hash", base: { ...head, hash: "hash" } } @@ -150,13 +148,8 @@ describe("Syncer per-file state and event delivery", () => { }, lastSeenUpdateId: 0 }, - getLatestDocumentByRelativePath: (path: RelativePath) => - path === "note.md" - ? { - documentId: "document", - relativePath: path - } - : undefined + findDocumentId: (path: RelativePath) => + path === "note.md" ? "document" : undefined } as unknown as Database; const websocket = { onWebSocketStatusChanged: new EventListeners< @@ -216,7 +209,10 @@ describe("Syncer per-file state and event delivery", () => { contentHead: (value: typeof head) => Record; } ).contentHead({ ...head, contentBase64: "secret" } as typeof head); - assert.deepEqual(projected, { vaultUpdateId: head.vaultUpdateId, contentSize: head.contentSize }); + assert.deepEqual(projected, { + vaultUpdateId: head.vaultUpdateId, + contentSize: head.contentSize + }); assert.strictEqual("contentBase64" in projected, false); }); }); diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 04e4f794..4ab618d6 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -42,6 +42,7 @@ import { import { isInternalPath } from "../utils/portable-path"; import { isBinary } from "../utils/is-binary"; import { isFileTypeMergeable } from "../utils/is-file-type-mergeable"; +import { decodeText } from "../utils/decode-text"; import type { FixedSizeDocumentCache } from "../utils/data-structures/fix-sized-cache"; import { AuthenticationError, @@ -126,14 +127,14 @@ export class Syncer { return false; } - const record = this.database.getLatestDocumentByRelativePath(path); - if (!record) return false; - const document = this.database.state.documents[record.documentId]; + const id = this.database.findDocumentId(path); + if (id === undefined) return false; + const document = this.database.state.documents[id]; if ( document?.observedHash === undefined || !document.base || document.observedHash !== document.base.hash || - this.database.state.fileManifest.entries[record.documentId] !== path + this.database.state.fileManifest.entries[id] !== path ) { return false; } @@ -143,16 +144,13 @@ export class Syncer { return false; const { pending } = this.database.state; - if ( - pending?.type === "content" && - pending.documentId === record.documentId - ) { + if (pending?.type === "content" && pending.documentId === id) { return false; } if ( pending?.type === "fileManifest" && - pending.request.entries[record.documentId] !== - this.database.state.fileManifest.entries[record.documentId] + pending.request.entries[id] !== + this.database.state.fileManifest.entries[id] ) { return false; } @@ -234,22 +232,18 @@ export class Syncer { if (rejection !== undefined) throw new PermanentSyncError(rejection); } - public async syncLocallyCreatedFile(_path: RelativePath): Promise { - this.localGeneration++; - this.pendingPaths.add(_path); + public async syncLocallyCreatedFile(path: RelativePath): Promise { await this.recordLocalChange({ type: "create", - path: _path, + path, changeId: uuid() }); } - public async syncLocallyDeletedFile(_path: RelativePath): Promise { - this.localGeneration++; - this.pendingPaths.add(_path); + public async syncLocallyDeletedFile(path: RelativePath): Promise { await this.recordLocalChange({ type: "delete", - path: _path, + path, changeId: uuid() }); } @@ -261,12 +255,6 @@ export class Syncer { oldPath?: RelativePath; relativePath: RelativePath; }): Promise { - // Content snapshots are re-read before writing. Only namespace - // notifications invalidate an identity plan; keystrokes must not starve - // a complete scan of unrelated documents. - if (oldPath !== undefined && oldPath !== "") this.localGeneration++; - this.pendingPaths.add(relativePath); - if (oldPath) this.pendingPaths.add(oldPath); if ( oldPath && !isInternalPath(oldPath) && @@ -278,8 +266,11 @@ export class Syncer { relativePath, changeId: uuid() }); + } else { + // Keystrokes wake reconciliation without invalidating identity plans. + this.pendingPaths.add(relativePath); + this.wake(); } - this.wake(); } /** Reload an uncertain metadata save before starting another attempt. */ @@ -289,10 +280,15 @@ export class Syncer { } private async recordLocalChange(change: LocalChange): Promise { + this.localGeneration++; + if (change.type === "move") { + this.pendingPaths.add(change.oldPath); + this.pendingPaths.add(change.relativePath); + } else this.pendingPaths.add(change.path); const previous = [...this.changes]; this.changes.push(change); const saving = this.notificationSaving.then(async () => { - await this.files.captureChange(change, previous); + this.files.captureChange(change, previous); await this.localChangeStorage.save(structuredClone(this.changes)); this.notificationSaveFailed = false; }); @@ -323,7 +319,7 @@ export class Syncer { if (rejection !== undefined) throw new PermanentSyncError(rejection); if (!this.isFirstSyncComplete) await this.service.resetHistory(); - await this.serverConfig.initialize(); + await this.serverConfig.getConfig(); if (!this.isFirstSyncComplete) await this.bootstrap(); if (!this.stopped) this.onReadyForEvents.trigger(); do { @@ -452,12 +448,7 @@ export class Syncer { (await this.serverConfig.getConfig()).mergeableFileExtensions ) ) { - const decode = (bytes: Uint8Array): string => - new TextDecoder("utf-8", { - fatal: true, - ignoreBOM: true - }).decode(bytes); - const value = diff(decode(parent), decode(content)); + const value = diff(decodeText(parent), decodeText(content)); // Match the server's work bound; large edit sets remain syncable. if (value.length <= 10_000) return { type: "Diff", value }; } @@ -482,13 +473,12 @@ export class Syncer { const hadChanges = this.changes.length > 0; this.unsyncablePaths = await scanLocalFiles( { - next: this.next(), + database: this.database, guard: this.localGuard(), changes: this.changes, files: this.files, ignored: (path) => this.settings.isIgnored(path), - oversized: (size) => this.settings.isOversized(size), - commit: async (next) => this.database.commit(next) + oversized: (size) => this.settings.isOversized(size) }, initial ); @@ -969,10 +959,7 @@ export class Syncer { : this.contentCache.get(parentVersionId); // A restart may have evicted the parent. Future uploads can use snapshots. if (!parent) return; - const text = new TextDecoder("utf-8", { - fatal: true, - ignoreBOM: true - }).decode(parent); + const text = decodeText(parent); bytes = new TextEncoder().encode(undiff(text, content.value)); } await this.cacheContent( diff --git a/frontend/sync-client/src/utils/await-all.test.ts b/frontend/sync-client/src/utils/await-all.test.ts index 09a22d89..a8661307 100644 --- a/frontend/sync-client/src/utils/await-all.test.ts +++ b/frontend/sync-client/src/utils/await-all.test.ts @@ -54,3 +54,21 @@ void test("awaitAll works with async functions", async () => { assert.strictEqual(results[0], "async"); assert.strictEqual(results[1], 123); }); + +void test("awaitAll drains unfinished work before reporting a rejection", async () => { + const pending = Promise.withResolvers(); + const error = new Error("failed first"); + let finished = false; + const checked = assert + .rejects(awaitAll([Promise.reject(error), pending.promise]), error) + .then(() => { + finished = true; + }); + try { + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(finished, false); + } finally { + pending.resolve(undefined); + } + await checked; +}); diff --git a/frontend/sync-client/src/utils/await-all.ts b/frontend/sync-client/src/utils/await-all.ts index 9406a6b8..1f91663c 100644 --- a/frontend/sync-client/src/utils/await-all.ts +++ b/frontend/sync-client/src/utils/await-all.ts @@ -1,25 +1,18 @@ -type PromiseTuple = readonly [ - ...{ [K in keyof T]: Promise } -]; - type ResolvedTuple = { - [K in keyof T]: T[K]; + -readonly [K in keyof T]: Awaited; }; -export const awaitAll = async ( - promises: PromiseTuple -): Promise> => { +/** Await every operation before returning results or throwing the first error. */ +export async function awaitAll( + promises: T +): Promise> { // eslint-disable-next-line no-restricted-properties const result = await Promise.allSettled(promises); - for (const res of result) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Array.map preserves tuple order and length. + return result.map((res) => { if (res.status === "rejected") { throw res.reason; } - } - - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return result.map( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (res) => (res as PromiseFulfilledResult).value - ) as ResolvedTuple; -}; + return res.value; + }) as ResolvedTuple; +} diff --git a/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts b/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts index 258db398..ff55da55 100644 --- a/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts +++ b/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts @@ -6,6 +6,10 @@ export function slowWebSocketFactory( jitterScaleInSeconds: number, logger: Logger ): typeof WebSocket { + const delay = async (): Promise => { + if (jitterScaleInSeconds > 0) + await sleep(Math.random() * jitterScaleInSeconds * 1000); + }; // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return class FlakyWebSocket extends WebSocket { private readonly receiving = new Lock(); @@ -13,10 +17,7 @@ export function slowWebSocketFactory( public set onopen(callback: ((event: Event) => void) | null) { super.onopen = async (event: Event): Promise => { - if (jitterScaleInSeconds > 0) { - await sleep(Math.random() * jitterScaleInSeconds * 1000); - } - + await delay(); callback?.(event); }; } @@ -24,12 +25,7 @@ export function slowWebSocketFactory( public set onmessage(callback: ((event: MessageEvent) => void) | null) { super.onmessage = async (event: MessageEvent): Promise => { await this.receiving.withLock(async () => { - if (jitterScaleInSeconds > 0) { - await sleep( - Math.random() * jitterScaleInSeconds * 1000 - ); - } - + await delay(); callback?.(event); }); }; @@ -37,18 +33,14 @@ export function slowWebSocketFactory( public set onclose(callback: ((event: CloseEvent) => void) | null) { super.onclose = async (event: CloseEvent): Promise => { - if (jitterScaleInSeconds > 0) { - await sleep(Math.random() * jitterScaleInSeconds * 1000); - } + await delay(); callback?.(event); }; } public set onerror(callback: ((event: Event) => void) | null) { super.onerror = async (event: Event): Promise => { - if (jitterScaleInSeconds > 0) { - await sleep(Math.random() * jitterScaleInSeconds * 1000); - } + await delay(); callback?.(event); }; } @@ -56,21 +48,15 @@ export function slowWebSocketFactory( public send( data: string | ArrayBufferLike | Blob | ArrayBufferView ): void { - this.waitingSend(data).catch((error: unknown) => { - logger.error(`Error sending WebSocket message: ${error}`); - }); - } - - private async waitingSend( - data: string | ArrayBufferLike | Blob | ArrayBufferView - ): Promise { // maintain message order - await this.sending.withLock(async () => { - if (jitterScaleInSeconds > 0) { - await sleep(Math.random() * jitterScaleInSeconds * 1000); - } - super.send(data); - }); + void this.sending + .withLock(async () => { + await delay(); + super.send(data); + }) + .catch((error: unknown) => { + logger.error(`Error sending WebSocket message: ${error}`); + }); } } as unknown as typeof WebSocket; } diff --git a/frontend/sync-client/src/utils/decode-text.test.ts b/frontend/sync-client/src/utils/decode-text.test.ts new file mode 100644 index 00000000..3cfc1dae --- /dev/null +++ b/frontend/sync-client/src/utils/decode-text.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { decodeText } from "./decode-text"; +import { isBinary } from "./is-binary"; + +test("text decoding preserves BOMs and line endings across successive files", () => { + for (const text of ["\uFEFFfirst\r\n", "plain\n", "\uFEFFé🙂\r\n"]) { + const bytes = new TextEncoder().encode(text); + assert.equal(isBinary(bytes), false); + assert.equal(decodeText(bytes), text); + } +}); + +test("invalid UTF-8 is binary and does not affect subsequent text decoding", () => { + const invalid = new Uint8Array([0xc3]); + assert.equal(isBinary(invalid), true); + assert.throws(() => decodeText(invalid)); + assert.equal(decodeText(new TextEncoder().encode("valid")), "valid"); + assert.equal(isBinary(new Uint8Array([0])), true); +}); diff --git a/frontend/sync-client/src/utils/decode-text.ts b/frontend/sync-client/src/utils/decode-text.ts new file mode 100644 index 00000000..6154a434 --- /dev/null +++ b/frontend/sync-client/src/utils/decode-text.ts @@ -0,0 +1,6 @@ +const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); + +/** Reject invalid UTF-8 and preserve a leading BOM as part of the file's text. */ +export function decodeText(content: Uint8Array): string { + return decoder.decode(content); +} diff --git a/frontend/sync-client/src/utils/is-binary.ts b/frontend/sync-client/src/utils/is-binary.ts index aac92711..a5b897e4 100644 --- a/frontend/sync-client/src/utils/is-binary.ts +++ b/frontend/sync-client/src/utils/is-binary.ts @@ -1,13 +1,11 @@ +import { decodeText } from "./decode-text"; + // Text is unlikely to contain null bytes, so we can use that to distinguish binary files. export function isBinary(content: Uint8Array): boolean { - for (const byte of content) { - if (byte === 0) { - return true; - } - } + if (content.includes(0)) return true; try { - new TextDecoder("utf-8", { fatal: true }).decode(content); + decodeText(content); } catch { return true; } diff --git a/frontend/sync-client/src/utils/portable-path.test.ts b/frontend/sync-client/src/utils/portable-path.test.ts index 1ada24d5..b8869eaf 100644 --- a/frontend/sync-client/src/utils/portable-path.test.ts +++ b/frontend/sync-client/src/utils/portable-path.test.ts @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { test } from "node:test"; -import { validatePortablePaths } from "./portable-path"; +import { allocatePortablePath, validatePortablePaths } from "./portable-path"; test("a small deeply nested manifest fits within a bounded validation heap", () => { const source = pathToFileURL(resolve(__dirname, "portable-path.ts")).href; @@ -26,6 +26,50 @@ test("a small deeply nested manifest fits within a bounded validation heap", () assert.equal(result.status, 0, result.stderr || String(result.error)); }); +test("conflict allocation renames the first obstructed component deterministically", () => { + const cases: { wanted: string; occupied: string[]; expected: string }[] = [ + { + wanted: "Notes/a.md", + occupied: ["Notes/b.md"], + expected: "Notes/a.md" + }, + { + wanted: "Notes/a.md", + occupied: ["notes/b.md"], + expected: "Notes (conflict new)/a.md" + }, + { + wanted: "Notes/a.md", + occupied: ["Notes"], + expected: "Notes (conflict new)/a.md" + }, + { + wanted: "Notes", + occupied: ["Notes/a.md"], + expected: "Notes (conflict new)" + }, + { + wanted: "note.md", + occupied: ["note.md", "note (conflict new).md"], + expected: "note (conflict new) (1).md" + }, + { + wanted: "Straße/a.md", + occupied: ["STRASSE/b.md"], + expected: "Straße (conflict new)/a.md" + } + ]; + for (const { wanted, occupied, expected } of cases) { + const actual = allocatePortablePath(wanted, "new", occupied); + assert.equal(actual, expected); + assert.equal( + allocatePortablePath(wanted, "new", occupied.toReversed()), + actual + ); + validatePortablePaths([...occupied, actual]); + } +}); + test("shared directories remain distinct from aliases and file ancestors", () => { validatePortablePaths(["Notes/one.md", "Notes/two.md", "Other/one.md"]); for (const paths of [ diff --git a/frontend/sync-client/src/utils/portable-path.ts b/frontend/sync-client/src/utils/portable-path.ts index fc86e5ec..443c2e29 100644 --- a/frontend/sync-client/src/utils/portable-path.ts +++ b/frontend/sync-client/src/utils/portable-path.ts @@ -1,5 +1,4 @@ import type { DocumentId, RelativePath } from "../persistence/database"; -import type { FileManifestEntries } from "../types/file-manifest-entries"; export const INTERNAL_DIRECTORY = ".vault-link-sync"; const MAX_COMPONENT_BYTES = 255; @@ -21,8 +20,10 @@ const reservedDeviceName = const pathKey = (path: RelativePath): string => path.normalize("NFC").toUpperCase().normalize("NFC"); -export const arePathAliases = (left: RelativePath, right: RelativePath): boolean => - pathKey(left) === pathKey(right); +export const arePathAliases = ( + left: RelativePath, + right: RelativePath +): boolean => pathKey(left) === pathKey(right); export function findPathWithSameSpelling( paths: Iterable, @@ -110,32 +111,24 @@ function fitName(name: string, suffix: string, file: boolean): string { ); } -function pathsConflict(left: RelativePath, right: RelativePath): boolean { - const a = left.split("/"), - b = right.split("/"); - for (let i = 0; i < Math.min(a.length, b.length); i++) { - if (!arePathAliases(a[i], b[i])) return false; - if (a[i] !== b[i]) return true; +/** First aliased component, or the file occupying a shared path prefix. */ +function conflictIndex( + left: readonly string[], + right: readonly string[] +): number | undefined { + const shared = Math.min(left.length, right.length); + for (let i = 0; i < shared; i++) { + if (!arePathAliases(left[i], right[i])) return; + if (left[i] !== right[i]) return i; } - return true; // Equal names, or a file occupies an ancestor directory. -} - -function isAvailablePath( - candidate: RelativePath, - id: DocumentId, - occupied: Readonly -): boolean { - validatePath(candidate); - return Object.entries(occupied).every( - ([otherId, path]) => otherId === id || !pathsConflict(candidate, path) - ); + return shared - 1; } /** Deterministic allocation also handles a file blocking an ancestor directory. */ export function allocatePortablePath( wanted: RelativePath, id: DocumentId, - occupied: Readonly + occupied: readonly RelativePath[] ): RelativePath { id = portableName(truncate(id, 64)); const originals = wanted.split("/"); @@ -150,44 +143,26 @@ export function allocatePortablePath( : portable; }); if (isInternalPath(parts.join("/"))) parts[0] = "_" + parts[0]; - let candidate = parts.join("/"); + const current = [...parts]; + const occupiedParts = occupied.map((path) => path.split("/")); const suffix = ` (conflict ${id})`; - const limit = (Object.keys(occupied).length + 1) * (parts.length + 1); + const limit = (occupied.length + 1) * (parts.length + 1); for (let attempt = 0; attempt <= limit; attempt++) { - try { - if (isAvailablePath(candidate, id, occupied)) return candidate; - } catch { - /* allocate below */ - } - const current = candidate.split("/"); - let conflictAt = current.length - 1; - for (let i = 0; i < current.length; i++) { - const prefix = current.slice(0, i + 1).join("/"); + let conflictAt: number | undefined = undefined; + for (const other of occupiedParts) { + const index = conflictIndex(current, other); if ( - Object.values(occupied).some((path) => { - const other = path - .split("/") - .slice(0, i + 1) - .join("/"); - return ( - arePathAliases(prefix, other) && - (prefix !== other || - path.split("/").length === i + 1 || - i === current.length - 1) - ); - }) - ) { - conflictAt = i; - break; - } + index !== undefined && + (conflictAt === undefined || index < conflictAt) + ) + conflictAt = index; } - const original = parts[conflictAt] ?? current[conflictAt]; + if (conflictAt === undefined) return current.join("/"); current[conflictAt] = fitName( - original, + parts[conflictAt], suffix + (attempt ? ` (${attempt})` : ""), conflictAt === current.length - 1 ); - candidate = current.join("/"); } throw new Error(`Unable to allocate a portable path: ${wanted}`); } diff --git a/frontend/sync-client/src/utils/rate-limit.ts b/frontend/sync-client/src/utils/rate-limit.ts index 03232def..2c97d355 100644 --- a/frontend/sync-client/src/utils/rate-limit.ts +++ b/frontend/sync-client/src/utils/rate-limit.ts @@ -1,56 +1,30 @@ import { sleep } from "./sleep"; -/** - * Creates a rate-limited version of a given asynchronous function. - * Ensures that the function is not called more frequently than specified by `minIntervalMs`. - * If the function is called while a previous call is still within the rate limit window, - * it will queue up the most recent arguments and execute them after the rate limit expires. - * Only the most recent call is preserved in the queue. - * - * @template T - Type of the function to be rate limited - * @param {T} fn - The asynchronous function to rate limit - * @param {number | (() => number)} minIntervalMs - Minimum interval in milliseconds between calls, - * or a function that returns the minimum interval - * @returns {(...args: Parameters) => ReturnType | Promise} A decorated function that respects the rate limit. - * Returns the original function's return type when executed, or undefined if the call was superseded by a newer one. - */ -export function rateLimit< - R, - T extends ( - ...args: any // eslint-disable-line @typescript-eslint/no-explicit-any - ) => Promise ->( - fn: T, +/** Coalesce calls during each interval; the first waiter runs the latest arguments. */ +export function rateLimit( + fn: (...args: Args) => Promise, minIntervalMs: number | (() => number) -): (...args: Parameters) => Promise { - let newArgs: Parameters | undefined = undefined; - let running: Promise | undefined = undefined; +): (...args: Args) => Promise { + let pending: Args | undefined = undefined; + let cooldown: Promise | undefined = undefined; - const decoratedFn = async ( - ...args: Parameters - ): Promise => { - if (running !== undefined) { - newArgs = args; - await running; + return async (...args: Args): Promise => { + if (cooldown) { + pending = args; + await cooldown; - // args might have changed while we were waiting - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (newArgs === undefined) { - // we weren't the first one to wake up, that means a newer - // invocation is running now, we can just bail - return; - } - args = newArgs; - newArgs = undefined; + // Another waiter may already have consumed the latest arguments. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Other calls mutate pending while awaiting the cooldown. + if (pending === undefined) return; + args = pending; + pending = undefined; } - running = sleep( + cooldown = sleep( typeof minIntervalMs === "function" ? minIntervalMs() : minIntervalMs ); return fn(...args); }; - - return decoratedFn; } diff --git a/frontend/test-support/engine-regressions.test.ts b/frontend/test-support/engine-regressions.test.ts index 8eda52b8..ce67fa47 100644 --- a/frontend/test-support/engine-regressions.test.ts +++ b/frontend/test-support/engine-regressions.test.ts @@ -183,13 +183,13 @@ test("conflict names respect component limits without imposing a total path limi const allocated = allocatePortablePath( wanted, "00000000-0000-4000-8000-000000000001", - { other: wanted } + [wanted] ); assert(Buffer.byteLength(allocated) <= 255); assert(allocated.endsWith(".md")); assert.notEqual(allocated, wanted); const longPath = "folder/".repeat(100) + "note.md"; - assert.equal(allocatePortablePath(longPath, "id", {}), longPath); + assert.equal(allocatePortablePath(longPath, "id", []), longPath); }); test("an unmaterialized file with a directory at its path is deconflicted", async () => { diff --git a/frontend/test-support/event-delivery.test.ts b/frontend/test-support/event-delivery.test.ts index d237903b..8f2701c7 100644 --- a/frontend/test-support/event-delivery.test.ts +++ b/frontend/test-support/event-delivery.test.ts @@ -68,11 +68,11 @@ for (const [count, delivery] of [ } }; const config = { - initialize: async () => { + getConfig: async () => { initializing.resolve(); await initialized.promise; + return { mergeableFileExtensions: ["md"] }; }, - getConfig: async () => ({ mergeableFileExtensions: ["md"] }), reset: () => {} } as unknown as ServerConfig; const events: EventRecord[] = Array.from( diff --git a/frontend/test-support/polling.test.ts b/frontend/test-support/polling.test.ts index 6a3b538d..c454e52a 100644 --- a/frontend/test-support/polling.test.ts +++ b/frontend/test-support/polling.test.ts @@ -111,7 +111,6 @@ async function fixture(syncIntervalMs: number | undefined) { async () => {} ); const config = { - initialize: async () => {}, getConfig: async () => ({ mergeableFileExtensions: [] }), reset: () => {} } as unknown as ServerConfig; diff --git a/frontend/test-support/recovery.test.ts b/frontend/test-support/recovery.test.ts index 0fe61fcd..cf09206c 100644 --- a/frontend/test-support/recovery.test.ts +++ b/frontend/test-support/recovery.test.ts @@ -396,13 +396,12 @@ test("scan failures cannot masquerade as deletions", async () => { }; await assert.rejects( scanLocalFiles({ - next: structuredClone(original), + database: runtime.database, changes: [], files: runtime.files, guard: () => { }, ignored: () => false, - oversized: () => false, - commit: (next) => runtime.database.commit(next) + oversized: () => false }), /permission denied/ ); @@ -424,13 +423,12 @@ for (const phase of ["before", "durable"]) { const scan = async () => { const runtime = current.restart(); await scanLocalFiles({ - next: structuredClone(runtime.database.state), + database: runtime.database, changes, files: runtime.files, guard: () => { }, ignored: () => false, - oversized: () => false, - commit: (next) => runtime.database.commit(next) + oversized: () => false }); return runtime.database.state; }; @@ -458,13 +456,12 @@ async function scanChanges( ): Promise { await runtime.database.recoverPersistence(); await scanLocalFiles({ - next: structuredClone(runtime.database.state), + database: runtime.database, changes, files: runtime.files, guard: () => { }, ignored: () => false, - oversized: () => false, - commit: (next) => runtime.database.commit(next) + oversized: () => false }); } @@ -574,13 +571,12 @@ test("normalization-sensitive disk does not lose a distinct decomposed filename" await runtime.database.commit(next); await current.disk.userWrite("e\u0301.md", bytes("decomposed")); await scanLocalFiles({ - next: structuredClone(runtime.database.state), + database: runtime.database, changes: [], files: runtime.files, guard: () => { }, ignored: () => false, - oversized: () => false, - commit: (next) => runtime.database.commit(next) + oversized: () => false }); assert.equal( Object.keys(runtime.database.state.local).length, diff --git a/frontend/test-support/sync-audit.test.ts b/frontend/test-support/sync-audit.test.ts index f26bbb33..6c477e78 100644 --- a/frontend/test-support/sync-audit.test.ts +++ b/frontend/test-support/sync-audit.test.ts @@ -269,7 +269,7 @@ test("a dirty cursor update must invalidate old precise positions", async () => const tracker = new CursorTracker( f.database, ws as unknown as WebSocketManager, - f.files, + f.files.fs, new EventListeners() ); let latest: import("../sync-client/src/types/maybe-outdated-client-cursors").MaybeOutdatedClientCursors[] = @@ -436,7 +436,7 @@ test("conflict allocation terminates when a truncated extension ends in a space" ` const {allocatePortablePath,validatePortablePaths} = require(${JSON.stringify(modulePath)}); const name = 'a.' + 'x'.repeat(62) + ' ' + 'x'.repeat(100); - const allocated = allocatePortablePath(name, 'new', {old:name}); + const allocated = allocatePortablePath(name, 'new', [name]); validatePortablePaths([name, allocated]); if (Buffer.byteLength(allocated) > 255) process.exit(2); ` diff --git a/frontend/test-support/sync-fixture.ts b/frontend/test-support/sync-fixture.ts index 2947ba87..69caa363 100644 --- a/frontend/test-support/sync-fixture.ts +++ b/frontend/test-support/sync-fixture.ts @@ -64,7 +64,6 @@ export async function fixture( async () => (await persistence.load()).database as StoredDatabase ); const config = { - initialize: async () => {}, getConfig: async () => ({ mergeableFileExtensions: ["md"] }), reset: () => {} } as unknown as ServerConfig;