From 3ba0b7a88b6e912e93f2635cd8389acfb8c4a613 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 8 Apr 2026 08:06:30 +0100 Subject: [PATCH 01/52] wip again --- .../offline-change-detector.ts | 247 ++++++++++ .../src/sync-operations/sync-event-queue.ts | 2 +- .../sync-client/src/sync-operations/syncer.ts | 422 ++---------------- 3 files changed, 286 insertions(+), 385 deletions(-) create mode 100644 frontend/sync-client/src/sync-operations/offline-change-detector.ts diff --git a/frontend/sync-client/src/sync-operations/offline-change-detector.ts b/frontend/sync-client/src/sync-operations/offline-change-detector.ts new file mode 100644 index 00000000..96b1126d --- /dev/null +++ b/frontend/sync-client/src/sync-operations/offline-change-detector.ts @@ -0,0 +1,247 @@ +import type { DocumentRecord, RelativePath } from "./types"; +import { SyncEventType } from "./types"; +import type { Logger } from "../tracing/logger"; +import { hash } from "../utils/hash"; +import type { FileOperations } from "../file-operations/file-operations"; +import { findMatchingFile } from "../utils/find-matching-file"; +import { FileNotFoundError } from "../errors/file-not-found-error"; +import type { SyncEventQueue } from "./sync-event-queue"; + +interface DocumentWithPath { + path: RelativePath; + record: DocumentRecord; +} + +interface SyncInstruction { + type: "update" | "create"; + relativePath: string; + oldPath?: string; +} + +interface OfflineChangeDetectorDeps { + logger: Logger; + operations: FileOperations; + queue: SyncEventQueue; +} + +/** + * Scans the local filesystem and the document database to determine + * which files were created, updated, moved, or deleted while the + * client was offline, then enqueues the appropriate sync events. + */ +export async function scheduleOfflineChanges( + deps: OfflineChangeDetectorDeps, + enqueueCreate: (path: RelativePath) => void, + enqueueUpdate: (args: { oldPath?: RelativePath; relativePath: RelativePath }) => void, + enqueueDelete: (path: RelativePath) => void, +): Promise { + const { logger, operations, queue } = deps; + + const allLocalFiles = await operations.listFilesRecursively(); + logger.info(`Scheduling sync for ${allLocalFiles.length} local files`); + + queue.clear(); + + const allDocuments = new Map(queue.allSettledDocuments()); + const locallyRenamedPaths = enqueueRenamedDocuments(deps, allDocuments); + + let deletedCandidates = await findLocallyDeletedFiles(operations, allDocuments); + + const instructions = await buildSyncInstructions( + deps, + allLocalFiles, + locallyRenamedPaths, + deletedCandidates, + ); + + // Enqueue deletes first + for (const { path } of deletedCandidates) { + logger.debug(`Document ${path} has been deleted locally, scheduling sync to delete it`); + enqueueDelete(path); + } + + // Then updates/moves + for (const instruction of instructions) { + if (instruction.type === "update") { + enqueueUpdate({ + oldPath: instruction.oldPath, + relativePath: instruction.relativePath, + }); + } + } + + // Creates last so the server can merge with existing documents + for (const instruction of instructions) { + if (instruction.type === "create") { + enqueueCreate(instruction.relativePath); + } + } +} + +function enqueueRenamedDocuments( + { queue, logger }: OfflineChangeDetectorDeps, + allDocuments: Map, +): Set { + const locallyRenamedPaths = new Set(); + + for (const [path, record] of allDocuments) { + const remoteRelPath = record.remoteRelativePath; + const hasLocalRename = remoteRelPath !== undefined && remoteRelPath !== path; + + if (hasLocalRename) { + queue.enqueue({ type: SyncEventType.SyncLocal, path }); + locallyRenamedPaths.add(path); + logger.debug(`Document ${path} was renamed locally (from ${remoteRelPath}), scheduling sync`); + } + } + + return locallyRenamedPaths; +} + +async function findLocallyDeletedFiles( + operations: FileOperations, + allDocuments: Map, +): Promise { + const result: DocumentWithPath[] = []; + + for (const [path, record] of allDocuments) { + if (!(await operations.exists(path))) { + result.push({ path, record }); + } + } + + return result; +} + +async function buildSyncInstructions( + deps: OfflineChangeDetectorDeps, + allLocalFiles: RelativePath[], + locallyRenamedPaths: Set, + deletedCandidates: DocumentWithPath[], +): Promise { + const { logger, operations, queue } = deps; + const instructions: SyncInstruction[] = []; + + for (const relativePath of allLocalFiles) { + if (locallyRenamedPaths.has(relativePath)) { + continue; + } + + const existingRecord = queue.getSettledDocumentByPath(relativePath); + + if (existingRecord !== undefined) { + const result = await handleExistingDocument( + deps, + relativePath, + existingRecord, + deletedCandidates, + ); + if (result !== undefined) { + if (result.updatedDeletedCandidates !== undefined) { + deletedCandidates = result.updatedDeletedCandidates; + } + if (result.instruction !== undefined) { + instructions.push(result.instruction); + } + continue; + } + + logger.debug( + `Document ${relativePath} might have been updated locally, scheduling sync to validate and update it`, + ); + instructions.push({ type: "update", relativePath }); + continue; + } + + const result = await handleNewFile(deps, relativePath, deletedCandidates); + if (result.updatedDeletedCandidates !== undefined) { + deletedCandidates = result.updatedDeletedCandidates; + } + instructions.push(result.instruction); + } + + return instructions; +} + +async function handleExistingDocument( + { logger, operations }: OfflineChangeDetectorDeps, + relativePath: RelativePath, + existingRecord: DocumentRecord, + deletedCandidates: DocumentWithPath[], +): Promise< + | { instruction?: SyncInstruction; updatedDeletedCandidates?: DocumentWithPath[] } + | undefined +> { + if (deletedCandidates.length === 0) { + return undefined; + } + + let contentHash: string | undefined; + try { + const bytes = await operations.read(relativePath); + contentHash = await hash(bytes); + } catch (e) { + if (e instanceof FileNotFoundError) return { instruction: undefined }; + throw e; + } + + if (contentHash === existingRecord.remoteHash) { + return undefined; + } + + const originalFile = await findMatchingFile(contentHash, deletedCandidates); + if (originalFile === undefined) { + return undefined; + } + + // This file was moved here from a different path, displacing the existing document + const updatedDeletedCandidates = [ + ...deletedCandidates.filter((item) => item.path !== originalFile.path), + { path: relativePath, record: existingRecord }, + ]; + + logger.debug( + `Document '${originalFile.path}' was moved to ${relativePath} (displacing existing document), scheduling sync to move it`, + ); + + return { + instruction: { type: "update", oldPath: originalFile.path, relativePath }, + updatedDeletedCandidates, + }; +} + +async function handleNewFile( + { logger, operations }: OfflineChangeDetectorDeps, + relativePath: RelativePath, + deletedCandidates: DocumentWithPath[], +): Promise<{ instruction: SyncInstruction; updatedDeletedCandidates?: DocumentWithPath[] }> { + let contentHash: string | undefined; + try { + const contentBytes = await operations.read(relativePath); + contentHash = await hash(contentBytes); + } catch (e) { + if (e instanceof FileNotFoundError) { + return { instruction: { type: "create", relativePath } }; + } + throw e; + } + + const originalFile = await findMatchingFile(contentHash, deletedCandidates); + if (originalFile !== undefined) { + const updatedDeletedCandidates = deletedCandidates.filter( + (item) => item.path !== originalFile.path, + ); + + logger.debug( + `Document '${originalFile.path}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it`, + ); + + return { + instruction: { type: "update", oldPath: originalFile.path, relativePath }, + updatedDeletedCandidates, + }; + } + + logger.debug(`Document ${relativePath} not found in database, scheduling sync to create it`); + return { instruction: { type: SyncEventType.Create, relativePath } }; +} diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index 623d9033..b2cebb1f 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -340,7 +340,7 @@ export class SyncEventQueue { return this.ignorePatterns.some((pattern) => pattern.test(path)); } - private removeAllEventsForDocumentId(documentId: DocumentId): void { + public removeAllEventsForDocumentId(documentId: DocumentId): void { for (let i = this.events.length - 1; i >= 0; i--) { const e = this.events[i]; if ( diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 908687d1..7a461b9c 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -7,10 +7,10 @@ import { type VaultUpdateId, } from "./types"; import type { Logger } from "../tracing/logger"; -import { EMPTY_HASH, hash } from "../utils/hash"; +import { hash } from "../utils/hash"; import type { Settings } from "../persistence/settings"; import type { FileOperations } from "../file-operations/file-operations"; -import { findMatchingFile } from "../utils/find-matching-file"; +import { scheduleOfflineChanges } from "./offline-change-detector"; import { SyncResetError } from "../errors/sync-reset-error"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate"; @@ -84,18 +84,13 @@ export class Syncer { } public syncLocallyCreatedFile(relativePath: RelativePath): void { - this.queue.enqueue({ type: SyncEventType.Create, path: relativePath, originalPath: relativePath }); + this.queue.enqueue({ type: SyncEventType.Create, path: relativePath }); this.ensureDraining(); } public syncLocallyDeletedFile(relativePath: RelativePath): void { - const record = this.queue.getSettledDocumentByPath(relativePath); - const documentId: DocumentId | Promise | undefined = - record?.documentId ?? this.queue.getCreatePromise(relativePath); - if (documentId === undefined) return; this.queue.enqueue({ type: SyncEventType.Delete, - documentId, path: relativePath, }); this.ensureDraining(); @@ -108,54 +103,7 @@ export class Syncer { oldPath?: RelativePath; relativePath: RelativePath; }): void { - if (oldPath === undefined) { - const record = this.queue.getSettledDocumentByPath(relativePath); - if (record === undefined) { - this.syncLocallyCreatedFile(relativePath); - return; - } - this.queue.enqueue({ - type: SyncEventType.SyncLocal, - documentId: record.documentId, - path: relativePath, - originalPath: relativePath, - }); - this.ensureDraining(); - return; - } - - // Handle rename - const sourceRecord = this.queue.getSettledDocumentByPath(oldPath); - if (sourceRecord !== undefined) { - // Capture the displaced document's version before - // moveDocument removes it from the store - const displacedRecord = this.queue.getSettledDocumentByPath(relativePath); - const displacedDocumentId = this.queue.moveDocument( - oldPath, - relativePath - ); - if (displacedDocumentId !== undefined) { - this.queue.enqueue({ - type: SyncEventType.Delete, - documentId: displacedDocumentId, - path: relativePath, - displacedAtVersion: displacedRecord?.parentVersionId, - }); - } - this.queue.enqueue({ - type: SyncEventType.SyncLocal, - documentId: sourceRecord.documentId, - path: relativePath, - originalPath: relativePath, - }); - } else { - // No settled document at the old path — enqueue a fresh - // create at the new path. If a Create for the old path is - // still in the queue it will fail with FileNotFoundError - // and reject its resolvers, cancelling any dependent events. - this.syncLocallyCreatedFile(relativePath); - } - + this.queue.enqueue({ type: SyncEventType.SyncLocal, path: relativePath, oldPath }); this.ensureDraining(); } @@ -209,17 +157,7 @@ export class Syncer { }); } - // The initial sync is a complete snapshot so we can jump the - // minimum straight to the max vaultUpdateId. Subsequent - // broadcasts use addSeenUpdateId (called per-event inside each - // processor) which tracks contiguous coverage and won't advance - // past gaps — correct for incremental updates but wrong for a - // snapshot whose IDs are intentionally sparse if (message.isInitialSync) { - this.queue.lastSeenUpdateId = Math.max( - ...message.documents.map((d) => d.vaultUpdateId), - this.queue.lastSeenUpdateId - ); this._isFirstSyncComplete = true; } @@ -259,181 +197,13 @@ export class Syncer { private async internalScheduleSyncForOfflineChanges(): Promise { - const allLocalFiles = await this.operations.listFilesRecursively(); - this.logger.info( - `Scheduling sync for ${allLocalFiles.length} local files` + await scheduleOfflineChanges( + { logger: this.logger, operations: this.operations, queue: this.queue }, + (path) => this.syncLocallyCreatedFile(path), + (args) => this.syncLocallyUpdatedFile(args), + (path) => this.syncLocallyDeletedFile(path), ); - // Clear stale event tracking from any previous drain - this.queue.clear(); - - // Detect documents whose local path diverges from the server path. - // This happens when a rename was recorded while sync was disabled. - const allDocuments = this.queue.allSettledDocuments(); - const locallyRenamedPaths = new Set(); - - for (const [path, record] of allDocuments) { - const remoteRelPath = record.remoteRelativePath; - const hasLocalRename = - remoteRelPath !== undefined && remoteRelPath !== path; - - if (hasLocalRename) { - // Enqueue a sync-local at the current (renamed) path; - // the processSyncLocal handler will detect the path - // divergence and send an update with the new path - this.queue.enqueue({ - type: SyncEventType.SyncLocal, - documentId: record.documentId, - path, - originalPath: path, - }); - locallyRenamedPaths.add(path); - } - } - - // Find files that have been deleted locally - interface DocumentWithPath { - path: RelativePath; - record: DocumentRecord; - } - let locallyPossiblyDeletedFiles: DocumentWithPath[] = []; - for (const [path, record] of allDocuments) { - if (!(await this.operations.exists(path))) { - locallyPossiblyDeletedFiles.push({ path, record }); - } - } - - interface Instruction { - type: "update" | "create"; - relativePath: string; - oldPath?: string; - } - const instructions: Instruction[] = []; - - for (const relativePath of allLocalFiles) { - if (locallyRenamedPaths.has(relativePath)) { - continue; - } - - const existingRecord = this.queue.getSettledDocumentByPath(relativePath); - - if (existingRecord !== undefined) { - // Verify the content actually belongs to this document. - // A file might exist at a known path but actually be a - // different document that was renamed here while offline - if (locallyPossiblyDeletedFiles.length > 0) { - let contentHash: string | undefined; - try { - const bytes = - await this.operations.read(relativePath); - contentHash = await hash(bytes); - } catch (e) { - if (e instanceof FileNotFoundError) continue; - throw e; - } - - if (contentHash !== existingRecord.remoteHash) { - const originalFile = await findMatchingFile( - contentHash, - locallyPossiblyDeletedFiles - ); - if (originalFile !== undefined) { - // This file was moved here from a different path - locallyPossiblyDeletedFiles.push({ - path: relativePath, - record: existingRecord - }); - locallyPossiblyDeletedFiles = - locallyPossiblyDeletedFiles.filter( - (item) => - item.path !== originalFile.path - ); - - this.logger.debug( - `Document '${originalFile.path}' was moved to ${relativePath} (displacing existing document), scheduling sync to move it` - ); - instructions.push({ - type: "update", - oldPath: originalFile.path, - relativePath - }); - continue; - } - } - } - - this.logger.debug( - `Document ${relativePath} might have been updated locally, scheduling sync to validate and update it` - ); - instructions.push({ type: "update", relativePath }); - continue; - } - - // Perhaps the file has been moved; check by looking at the deleted files - let contentHash: string | undefined = undefined; - try { - const contentBytes = await this.operations.read(relativePath); - contentHash = await hash(contentBytes); - } catch (e) { - if (e instanceof FileNotFoundError) { - continue; - } - throw e; - } - - const originalFile = await findMatchingFile( - contentHash, - locallyPossiblyDeletedFiles - ); - if (originalFile !== undefined) { - locallyPossiblyDeletedFiles = - locallyPossiblyDeletedFiles.filter( - (item) => item.path !== originalFile.path - ); - - this.logger.debug( - `Document '${originalFile.path}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it` - ); - - instructions.push({ - type: "update", - oldPath: originalFile.path, - relativePath - }); - continue; - } - - this.logger.debug( - `Document ${relativePath} not found in database, scheduling sync to create it` - ); - instructions.push({ type: SyncEventType.Create, relativePath }); - } - - // Enqueue deletes first - for (const { path } of locallyPossiblyDeletedFiles) { - this.logger.debug( - `Document ${path} has been deleted locally, scheduling sync to delete it` - ); - this.syncLocallyDeletedFile(path); - } - - // Then updates/moves - for (const instruction of instructions) { - if (instruction.type === "update") { - this.syncLocallyUpdatedFile({ - oldPath: instruction.oldPath, - relativePath: instruction.relativePath - }); - } - } - - // Creates last so the server can merge with existing documents - for (const instruction of instructions) { - if (instruction.type === "create") { - this.syncLocallyCreatedFile(instruction.relativePath); - } - } - await this.scheduleDrain(); } @@ -451,7 +221,7 @@ export class Syncer { } private async drain(): Promise { - let event = this.queue.next(); + let event = await this.queue.next(); while (event !== undefined) { try { await this.processEvent(event); @@ -465,7 +235,7 @@ export class Syncer { ); } this.notifyRemainingOperationsChanged(); - event = this.queue.next(); + event = await this.queue.next(); } } @@ -503,44 +273,8 @@ export class Syncer { } return; } - if ( - e instanceof HttpClientError && - event.type === SyncEventType.SyncLocal - ) { - // The server rejected the update (e.g. document was - // deleted). Re-create only if local content differs - // from the last synced version — otherwise the remote - // delete should win - const doc = this.queue.getDocumentByDocumentId( - event.documentId - ); - if (doc === undefined) return; - const { path: eventPath, record } = doc; - if (await this.operations.exists(eventPath)) { - const localBytes = - await this.operations.read(eventPath); - const localHash = await hash(localBytes); - if (localHash !== record.remoteHash) { - this.logger.info( - `Server rejected update for ${eventPath} but local content changed, re-creating` - ); - this.queue.removeDocument(eventPath); - this.syncLocallyCreatedFile(eventPath); - return; - } - } - this.logger.info( - `Server rejected update for ${eventPath} (${e.message}), removing local copy` - ); - this.queue.removeDocument(eventPath); - await this.operations.delete(eventPath); - return; - } if (e instanceof HttpClientError) { - // Server rejected a request (e.g. updating a deleted - // document during sync-remote processing). Not an - // error — the next offline scan will reconcile - this.logger.info( + this.logger.error( `Server rejected ${event.type} request: ${e.message}` ); return; @@ -574,77 +308,33 @@ export class Syncer { contentBytes }); - event.resolvers?.resolve(response.documentId); // Handle concurrent move & creation: the server merged our create // with an existing document that we also have locally at a different path const existingDoc = this.queue.getDocumentByDocumentId( response.documentId ); + + // need to merge in db if (existingDoc !== undefined && existingDoc.path !== effectivePath) { - this.logger.info( - `Merging existing document ${existingDoc.path} into ${effectivePath} after concurrent move & creation` - ); - await this.operations.delete(existingDoc.path); - this.queue.removeDocument(existingDoc.path); + // this.logger.info( + // `Merging existing document ${existingDoc.path} into ${effectivePath} after concurrent move & creation` + // ); + // await this.operations.delete(existingDoc.path); + // this.queue.removeDocument(existingDoc.path); + // if (!this.queue.getDocumentByDocumentId(existingDoc.record.documentId)) { + // this.queue.removeAllEventsForDocumentId(existingDoc.record.documentId); + // } + // } } - // When the server deconflicts the create to a different path, another - // document may now occupy the original path (downloaded while the - // create was in flight). handleMaybeMergingResponse would move the - // file AND the foreign document's record to the deconflicted path, - // then overwrite it — orphaning the foreign document. Handle this - // by writing directly to the deconflicted path instead of moving - const foreignRecord = this.queue.getSettledDocumentByPath(effectivePath); - const pathOccupiedByForeignDocument = - response.relativePath !== effectivePath && - foreignRecord !== undefined && - foreignRecord.documentId !== response.documentId; - if (pathOccupiedByForeignDocument) { - const actualPath = response.relativePath; - - if ("type" in response && response.type === "MergingUpdate") { - const responseBytes = base64ToBytes(response.contentBase64); - await this.operations.create(actualPath, responseBytes); - const afterWriteBytes = - await this.operations.read(actualPath); - const afterWriteHash = await hash(afterWriteBytes); - this.queue.setDocument(actualPath, { - documentId: response.documentId, - parentVersionId: response.vaultUpdateId, - remoteHash: afterWriteHash, - remoteRelativePath: response.relativePath - }); - await this.updateCache( - response.vaultUpdateId, - responseBytes, - actualPath - ); - } else { - await this.operations.create(actualPath, contentBytes); - this.queue.setDocument(actualPath, { - documentId: response.documentId, - parentVersionId: response.vaultUpdateId, - remoteHash: contentHash, - remoteRelativePath: response.relativePath - }); - await this.updateCache( - response.vaultUpdateId, - contentBytes, - actualPath - ); - } - } else { - await this.handleMaybeMergingResponse({ - path: effectivePath, - response, - contentHash, - originalContentBytes: contentBytes - }); - } - - this.queue.addSeenUpdateId(response.vaultUpdateId); + await this.handleMaybeMergingResponse({ + path: effectivePath, + response, + contentHash, + originalContentBytes: contentBytes + }); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -660,8 +350,6 @@ export class Syncer { private async processDelete( event: Extract ): Promise { - const { path } = event; - let documentId: DocumentId; if (typeof event.documentId === "string") { documentId = event.documentId; @@ -676,40 +364,21 @@ export class Syncer { } } - // For displacement deletes (side effect of a rename), check - // if another client updated the document since our last known - // version. If so, skip the delete to preserve their edits - if (event.displacedAtVersion !== undefined) { - const latest = await this.syncService.get({ documentId }); - if ( - !latest.isDeleted && - latest.vaultUpdateId > event.displacedAtVersion - ) { - this.logger.info( - `Skipping displacement delete for ${documentId} — document was updated by another client` - ); - return; - } - } - - // Use the document's current path from the store if available, - // otherwise fall back to the path from the event (e.g. when the - // document was displaced by a move and already removed from the store) const doc = this.queue.getDocumentByDocumentId(documentId); - const relativePath = doc?.path ?? path; + if (doc === undefined) { + this.logger.debug( + `Skipping delete for unknown documentId ${documentId}` + ); + return; + } + const relativePath = doc.path; const response = await this.syncService.delete({ documentId, relativePath }); - // Only remove the document record if it still belongs to this - // documentId; the path may have been reused by a different document - // (e.g. after a move-to-occupied-path) - if (doc !== undefined) { - this.queue.removeDocument(doc.path); - } - this.queue.addSeenUpdateId(response.vaultUpdateId); + this.queue.removeDocument(doc.path); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -781,7 +450,6 @@ export class Syncer { originalContentBytes: contentBytes }); - this.queue.addSeenUpdateId(response.vaultUpdateId); const isMerge = "type" in response && response.type === "MergingUpdate"; @@ -815,7 +483,6 @@ export class Syncer { this.logger.debug( `Document ${existingDoc.path} is already up-to-date` ); - this.queue.addSeenUpdateId(remoteVersion.vaultUpdateId); return; } @@ -831,7 +498,6 @@ export class Syncer { this.logger.debug( `Document ${remoteVersion.relativePath} has been deleted remotely, no need to sync` ); - this.queue.addSeenUpdateId(remoteVersion.vaultUpdateId); return; } @@ -858,13 +524,11 @@ export class Syncer { // Local changes survive; re-upload as a new document this.queue.removeDocument(currentPath); this.syncLocallyCreatedFile(currentPath); - this.queue.addSeenUpdateId(remoteVersion.vaultUpdateId); return; } await this.operations.delete(currentPath); this.queue.removeDocument(currentPath); - this.queue.addSeenUpdateId(remoteVersion.vaultUpdateId); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -897,7 +561,6 @@ export class Syncer { await this.operations.delete(currentPath); this.queue.removeDocument(currentPath); } - this.queue.addSeenUpdateId(fullVersion.vaultUpdateId); return; } @@ -920,7 +583,6 @@ export class Syncer { originalContentBytes: contentBytes }); - this.queue.addSeenUpdateId(response.vaultUpdateId); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -976,7 +638,6 @@ export class Syncer { responseBytes, actualPath ); - this.queue.addSeenUpdateId(fullVersion.vaultUpdateId); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -1058,7 +719,6 @@ export class Syncer { remoteVersion.relativePath ); - this.queue.addSeenUpdateId(remoteVersion.vaultUpdateId); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -1129,7 +789,6 @@ export class Syncer { const record = this.queue.getSettledDocumentByPath(path); if (record !== undefined && localHash !== record.remoteHash) { this.queue.removeDocument(path); - this.queue.addSeenUpdateId(response.vaultUpdateId); this.syncLocallyCreatedFile(path); return; } @@ -1156,12 +815,7 @@ export class Syncer { await this.operations.read(displacedPath); const displacedHash = await hash(displacedBytes); if (displacedHash !== displacedRecord.remoteHash) { - this.queue.enqueue({ - type: SyncEventType.SyncLocal, - documentId: displacedRecord.documentId, - path: displacedPath, - originalPath: displacedPath, - }); + this.queue.enqueue({ type: SyncEventType.SyncLocal, path: displacedPath }); } } } From 5ec523234bbf934e355a988bb149d1fa0e057463 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 21 Apr 2026 19:42:45 +0100 Subject: [PATCH 02/52] . --- .../offline-change-detector.ts | 247 ++++++++++ .../src/sync-operations/sync-event-queue.ts | 2 +- .../sync-client/src/sync-operations/syncer.ts | 422 ++---------------- 3 files changed, 286 insertions(+), 385 deletions(-) create mode 100644 frontend/sync-client/src/sync-operations/offline-change-detector.ts diff --git a/frontend/sync-client/src/sync-operations/offline-change-detector.ts b/frontend/sync-client/src/sync-operations/offline-change-detector.ts new file mode 100644 index 00000000..96b1126d --- /dev/null +++ b/frontend/sync-client/src/sync-operations/offline-change-detector.ts @@ -0,0 +1,247 @@ +import type { DocumentRecord, RelativePath } from "./types"; +import { SyncEventType } from "./types"; +import type { Logger } from "../tracing/logger"; +import { hash } from "../utils/hash"; +import type { FileOperations } from "../file-operations/file-operations"; +import { findMatchingFile } from "../utils/find-matching-file"; +import { FileNotFoundError } from "../errors/file-not-found-error"; +import type { SyncEventQueue } from "./sync-event-queue"; + +interface DocumentWithPath { + path: RelativePath; + record: DocumentRecord; +} + +interface SyncInstruction { + type: "update" | "create"; + relativePath: string; + oldPath?: string; +} + +interface OfflineChangeDetectorDeps { + logger: Logger; + operations: FileOperations; + queue: SyncEventQueue; +} + +/** + * Scans the local filesystem and the document database to determine + * which files were created, updated, moved, or deleted while the + * client was offline, then enqueues the appropriate sync events. + */ +export async function scheduleOfflineChanges( + deps: OfflineChangeDetectorDeps, + enqueueCreate: (path: RelativePath) => void, + enqueueUpdate: (args: { oldPath?: RelativePath; relativePath: RelativePath }) => void, + enqueueDelete: (path: RelativePath) => void, +): Promise { + const { logger, operations, queue } = deps; + + const allLocalFiles = await operations.listFilesRecursively(); + logger.info(`Scheduling sync for ${allLocalFiles.length} local files`); + + queue.clear(); + + const allDocuments = new Map(queue.allSettledDocuments()); + const locallyRenamedPaths = enqueueRenamedDocuments(deps, allDocuments); + + let deletedCandidates = await findLocallyDeletedFiles(operations, allDocuments); + + const instructions = await buildSyncInstructions( + deps, + allLocalFiles, + locallyRenamedPaths, + deletedCandidates, + ); + + // Enqueue deletes first + for (const { path } of deletedCandidates) { + logger.debug(`Document ${path} has been deleted locally, scheduling sync to delete it`); + enqueueDelete(path); + } + + // Then updates/moves + for (const instruction of instructions) { + if (instruction.type === "update") { + enqueueUpdate({ + oldPath: instruction.oldPath, + relativePath: instruction.relativePath, + }); + } + } + + // Creates last so the server can merge with existing documents + for (const instruction of instructions) { + if (instruction.type === "create") { + enqueueCreate(instruction.relativePath); + } + } +} + +function enqueueRenamedDocuments( + { queue, logger }: OfflineChangeDetectorDeps, + allDocuments: Map, +): Set { + const locallyRenamedPaths = new Set(); + + for (const [path, record] of allDocuments) { + const remoteRelPath = record.remoteRelativePath; + const hasLocalRename = remoteRelPath !== undefined && remoteRelPath !== path; + + if (hasLocalRename) { + queue.enqueue({ type: SyncEventType.SyncLocal, path }); + locallyRenamedPaths.add(path); + logger.debug(`Document ${path} was renamed locally (from ${remoteRelPath}), scheduling sync`); + } + } + + return locallyRenamedPaths; +} + +async function findLocallyDeletedFiles( + operations: FileOperations, + allDocuments: Map, +): Promise { + const result: DocumentWithPath[] = []; + + for (const [path, record] of allDocuments) { + if (!(await operations.exists(path))) { + result.push({ path, record }); + } + } + + return result; +} + +async function buildSyncInstructions( + deps: OfflineChangeDetectorDeps, + allLocalFiles: RelativePath[], + locallyRenamedPaths: Set, + deletedCandidates: DocumentWithPath[], +): Promise { + const { logger, operations, queue } = deps; + const instructions: SyncInstruction[] = []; + + for (const relativePath of allLocalFiles) { + if (locallyRenamedPaths.has(relativePath)) { + continue; + } + + const existingRecord = queue.getSettledDocumentByPath(relativePath); + + if (existingRecord !== undefined) { + const result = await handleExistingDocument( + deps, + relativePath, + existingRecord, + deletedCandidates, + ); + if (result !== undefined) { + if (result.updatedDeletedCandidates !== undefined) { + deletedCandidates = result.updatedDeletedCandidates; + } + if (result.instruction !== undefined) { + instructions.push(result.instruction); + } + continue; + } + + logger.debug( + `Document ${relativePath} might have been updated locally, scheduling sync to validate and update it`, + ); + instructions.push({ type: "update", relativePath }); + continue; + } + + const result = await handleNewFile(deps, relativePath, deletedCandidates); + if (result.updatedDeletedCandidates !== undefined) { + deletedCandidates = result.updatedDeletedCandidates; + } + instructions.push(result.instruction); + } + + return instructions; +} + +async function handleExistingDocument( + { logger, operations }: OfflineChangeDetectorDeps, + relativePath: RelativePath, + existingRecord: DocumentRecord, + deletedCandidates: DocumentWithPath[], +): Promise< + | { instruction?: SyncInstruction; updatedDeletedCandidates?: DocumentWithPath[] } + | undefined +> { + if (deletedCandidates.length === 0) { + return undefined; + } + + let contentHash: string | undefined; + try { + const bytes = await operations.read(relativePath); + contentHash = await hash(bytes); + } catch (e) { + if (e instanceof FileNotFoundError) return { instruction: undefined }; + throw e; + } + + if (contentHash === existingRecord.remoteHash) { + return undefined; + } + + const originalFile = await findMatchingFile(contentHash, deletedCandidates); + if (originalFile === undefined) { + return undefined; + } + + // This file was moved here from a different path, displacing the existing document + const updatedDeletedCandidates = [ + ...deletedCandidates.filter((item) => item.path !== originalFile.path), + { path: relativePath, record: existingRecord }, + ]; + + logger.debug( + `Document '${originalFile.path}' was moved to ${relativePath} (displacing existing document), scheduling sync to move it`, + ); + + return { + instruction: { type: "update", oldPath: originalFile.path, relativePath }, + updatedDeletedCandidates, + }; +} + +async function handleNewFile( + { logger, operations }: OfflineChangeDetectorDeps, + relativePath: RelativePath, + deletedCandidates: DocumentWithPath[], +): Promise<{ instruction: SyncInstruction; updatedDeletedCandidates?: DocumentWithPath[] }> { + let contentHash: string | undefined; + try { + const contentBytes = await operations.read(relativePath); + contentHash = await hash(contentBytes); + } catch (e) { + if (e instanceof FileNotFoundError) { + return { instruction: { type: "create", relativePath } }; + } + throw e; + } + + const originalFile = await findMatchingFile(contentHash, deletedCandidates); + if (originalFile !== undefined) { + const updatedDeletedCandidates = deletedCandidates.filter( + (item) => item.path !== originalFile.path, + ); + + logger.debug( + `Document '${originalFile.path}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it`, + ); + + return { + instruction: { type: "update", oldPath: originalFile.path, relativePath }, + updatedDeletedCandidates, + }; + } + + logger.debug(`Document ${relativePath} not found in database, scheduling sync to create it`); + return { instruction: { type: SyncEventType.Create, relativePath } }; +} diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index 623d9033..b2cebb1f 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -340,7 +340,7 @@ export class SyncEventQueue { return this.ignorePatterns.some((pattern) => pattern.test(path)); } - private removeAllEventsForDocumentId(documentId: DocumentId): void { + public removeAllEventsForDocumentId(documentId: DocumentId): void { for (let i = this.events.length - 1; i >= 0; i--) { const e = this.events[i]; if ( diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 908687d1..7a461b9c 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -7,10 +7,10 @@ import { type VaultUpdateId, } from "./types"; import type { Logger } from "../tracing/logger"; -import { EMPTY_HASH, hash } from "../utils/hash"; +import { hash } from "../utils/hash"; import type { Settings } from "../persistence/settings"; import type { FileOperations } from "../file-operations/file-operations"; -import { findMatchingFile } from "../utils/find-matching-file"; +import { scheduleOfflineChanges } from "./offline-change-detector"; import { SyncResetError } from "../errors/sync-reset-error"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate"; @@ -84,18 +84,13 @@ export class Syncer { } public syncLocallyCreatedFile(relativePath: RelativePath): void { - this.queue.enqueue({ type: SyncEventType.Create, path: relativePath, originalPath: relativePath }); + this.queue.enqueue({ type: SyncEventType.Create, path: relativePath }); this.ensureDraining(); } public syncLocallyDeletedFile(relativePath: RelativePath): void { - const record = this.queue.getSettledDocumentByPath(relativePath); - const documentId: DocumentId | Promise | undefined = - record?.documentId ?? this.queue.getCreatePromise(relativePath); - if (documentId === undefined) return; this.queue.enqueue({ type: SyncEventType.Delete, - documentId, path: relativePath, }); this.ensureDraining(); @@ -108,54 +103,7 @@ export class Syncer { oldPath?: RelativePath; relativePath: RelativePath; }): void { - if (oldPath === undefined) { - const record = this.queue.getSettledDocumentByPath(relativePath); - if (record === undefined) { - this.syncLocallyCreatedFile(relativePath); - return; - } - this.queue.enqueue({ - type: SyncEventType.SyncLocal, - documentId: record.documentId, - path: relativePath, - originalPath: relativePath, - }); - this.ensureDraining(); - return; - } - - // Handle rename - const sourceRecord = this.queue.getSettledDocumentByPath(oldPath); - if (sourceRecord !== undefined) { - // Capture the displaced document's version before - // moveDocument removes it from the store - const displacedRecord = this.queue.getSettledDocumentByPath(relativePath); - const displacedDocumentId = this.queue.moveDocument( - oldPath, - relativePath - ); - if (displacedDocumentId !== undefined) { - this.queue.enqueue({ - type: SyncEventType.Delete, - documentId: displacedDocumentId, - path: relativePath, - displacedAtVersion: displacedRecord?.parentVersionId, - }); - } - this.queue.enqueue({ - type: SyncEventType.SyncLocal, - documentId: sourceRecord.documentId, - path: relativePath, - originalPath: relativePath, - }); - } else { - // No settled document at the old path — enqueue a fresh - // create at the new path. If a Create for the old path is - // still in the queue it will fail with FileNotFoundError - // and reject its resolvers, cancelling any dependent events. - this.syncLocallyCreatedFile(relativePath); - } - + this.queue.enqueue({ type: SyncEventType.SyncLocal, path: relativePath, oldPath }); this.ensureDraining(); } @@ -209,17 +157,7 @@ export class Syncer { }); } - // The initial sync is a complete snapshot so we can jump the - // minimum straight to the max vaultUpdateId. Subsequent - // broadcasts use addSeenUpdateId (called per-event inside each - // processor) which tracks contiguous coverage and won't advance - // past gaps — correct for incremental updates but wrong for a - // snapshot whose IDs are intentionally sparse if (message.isInitialSync) { - this.queue.lastSeenUpdateId = Math.max( - ...message.documents.map((d) => d.vaultUpdateId), - this.queue.lastSeenUpdateId - ); this._isFirstSyncComplete = true; } @@ -259,181 +197,13 @@ export class Syncer { private async internalScheduleSyncForOfflineChanges(): Promise { - const allLocalFiles = await this.operations.listFilesRecursively(); - this.logger.info( - `Scheduling sync for ${allLocalFiles.length} local files` + await scheduleOfflineChanges( + { logger: this.logger, operations: this.operations, queue: this.queue }, + (path) => this.syncLocallyCreatedFile(path), + (args) => this.syncLocallyUpdatedFile(args), + (path) => this.syncLocallyDeletedFile(path), ); - // Clear stale event tracking from any previous drain - this.queue.clear(); - - // Detect documents whose local path diverges from the server path. - // This happens when a rename was recorded while sync was disabled. - const allDocuments = this.queue.allSettledDocuments(); - const locallyRenamedPaths = new Set(); - - for (const [path, record] of allDocuments) { - const remoteRelPath = record.remoteRelativePath; - const hasLocalRename = - remoteRelPath !== undefined && remoteRelPath !== path; - - if (hasLocalRename) { - // Enqueue a sync-local at the current (renamed) path; - // the processSyncLocal handler will detect the path - // divergence and send an update with the new path - this.queue.enqueue({ - type: SyncEventType.SyncLocal, - documentId: record.documentId, - path, - originalPath: path, - }); - locallyRenamedPaths.add(path); - } - } - - // Find files that have been deleted locally - interface DocumentWithPath { - path: RelativePath; - record: DocumentRecord; - } - let locallyPossiblyDeletedFiles: DocumentWithPath[] = []; - for (const [path, record] of allDocuments) { - if (!(await this.operations.exists(path))) { - locallyPossiblyDeletedFiles.push({ path, record }); - } - } - - interface Instruction { - type: "update" | "create"; - relativePath: string; - oldPath?: string; - } - const instructions: Instruction[] = []; - - for (const relativePath of allLocalFiles) { - if (locallyRenamedPaths.has(relativePath)) { - continue; - } - - const existingRecord = this.queue.getSettledDocumentByPath(relativePath); - - if (existingRecord !== undefined) { - // Verify the content actually belongs to this document. - // A file might exist at a known path but actually be a - // different document that was renamed here while offline - if (locallyPossiblyDeletedFiles.length > 0) { - let contentHash: string | undefined; - try { - const bytes = - await this.operations.read(relativePath); - contentHash = await hash(bytes); - } catch (e) { - if (e instanceof FileNotFoundError) continue; - throw e; - } - - if (contentHash !== existingRecord.remoteHash) { - const originalFile = await findMatchingFile( - contentHash, - locallyPossiblyDeletedFiles - ); - if (originalFile !== undefined) { - // This file was moved here from a different path - locallyPossiblyDeletedFiles.push({ - path: relativePath, - record: existingRecord - }); - locallyPossiblyDeletedFiles = - locallyPossiblyDeletedFiles.filter( - (item) => - item.path !== originalFile.path - ); - - this.logger.debug( - `Document '${originalFile.path}' was moved to ${relativePath} (displacing existing document), scheduling sync to move it` - ); - instructions.push({ - type: "update", - oldPath: originalFile.path, - relativePath - }); - continue; - } - } - } - - this.logger.debug( - `Document ${relativePath} might have been updated locally, scheduling sync to validate and update it` - ); - instructions.push({ type: "update", relativePath }); - continue; - } - - // Perhaps the file has been moved; check by looking at the deleted files - let contentHash: string | undefined = undefined; - try { - const contentBytes = await this.operations.read(relativePath); - contentHash = await hash(contentBytes); - } catch (e) { - if (e instanceof FileNotFoundError) { - continue; - } - throw e; - } - - const originalFile = await findMatchingFile( - contentHash, - locallyPossiblyDeletedFiles - ); - if (originalFile !== undefined) { - locallyPossiblyDeletedFiles = - locallyPossiblyDeletedFiles.filter( - (item) => item.path !== originalFile.path - ); - - this.logger.debug( - `Document '${originalFile.path}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it` - ); - - instructions.push({ - type: "update", - oldPath: originalFile.path, - relativePath - }); - continue; - } - - this.logger.debug( - `Document ${relativePath} not found in database, scheduling sync to create it` - ); - instructions.push({ type: SyncEventType.Create, relativePath }); - } - - // Enqueue deletes first - for (const { path } of locallyPossiblyDeletedFiles) { - this.logger.debug( - `Document ${path} has been deleted locally, scheduling sync to delete it` - ); - this.syncLocallyDeletedFile(path); - } - - // Then updates/moves - for (const instruction of instructions) { - if (instruction.type === "update") { - this.syncLocallyUpdatedFile({ - oldPath: instruction.oldPath, - relativePath: instruction.relativePath - }); - } - } - - // Creates last so the server can merge with existing documents - for (const instruction of instructions) { - if (instruction.type === "create") { - this.syncLocallyCreatedFile(instruction.relativePath); - } - } - await this.scheduleDrain(); } @@ -451,7 +221,7 @@ export class Syncer { } private async drain(): Promise { - let event = this.queue.next(); + let event = await this.queue.next(); while (event !== undefined) { try { await this.processEvent(event); @@ -465,7 +235,7 @@ export class Syncer { ); } this.notifyRemainingOperationsChanged(); - event = this.queue.next(); + event = await this.queue.next(); } } @@ -503,44 +273,8 @@ export class Syncer { } return; } - if ( - e instanceof HttpClientError && - event.type === SyncEventType.SyncLocal - ) { - // The server rejected the update (e.g. document was - // deleted). Re-create only if local content differs - // from the last synced version — otherwise the remote - // delete should win - const doc = this.queue.getDocumentByDocumentId( - event.documentId - ); - if (doc === undefined) return; - const { path: eventPath, record } = doc; - if (await this.operations.exists(eventPath)) { - const localBytes = - await this.operations.read(eventPath); - const localHash = await hash(localBytes); - if (localHash !== record.remoteHash) { - this.logger.info( - `Server rejected update for ${eventPath} but local content changed, re-creating` - ); - this.queue.removeDocument(eventPath); - this.syncLocallyCreatedFile(eventPath); - return; - } - } - this.logger.info( - `Server rejected update for ${eventPath} (${e.message}), removing local copy` - ); - this.queue.removeDocument(eventPath); - await this.operations.delete(eventPath); - return; - } if (e instanceof HttpClientError) { - // Server rejected a request (e.g. updating a deleted - // document during sync-remote processing). Not an - // error — the next offline scan will reconcile - this.logger.info( + this.logger.error( `Server rejected ${event.type} request: ${e.message}` ); return; @@ -574,77 +308,33 @@ export class Syncer { contentBytes }); - event.resolvers?.resolve(response.documentId); // Handle concurrent move & creation: the server merged our create // with an existing document that we also have locally at a different path const existingDoc = this.queue.getDocumentByDocumentId( response.documentId ); + + // need to merge in db if (existingDoc !== undefined && existingDoc.path !== effectivePath) { - this.logger.info( - `Merging existing document ${existingDoc.path} into ${effectivePath} after concurrent move & creation` - ); - await this.operations.delete(existingDoc.path); - this.queue.removeDocument(existingDoc.path); + // this.logger.info( + // `Merging existing document ${existingDoc.path} into ${effectivePath} after concurrent move & creation` + // ); + // await this.operations.delete(existingDoc.path); + // this.queue.removeDocument(existingDoc.path); + // if (!this.queue.getDocumentByDocumentId(existingDoc.record.documentId)) { + // this.queue.removeAllEventsForDocumentId(existingDoc.record.documentId); + // } + // } } - // When the server deconflicts the create to a different path, another - // document may now occupy the original path (downloaded while the - // create was in flight). handleMaybeMergingResponse would move the - // file AND the foreign document's record to the deconflicted path, - // then overwrite it — orphaning the foreign document. Handle this - // by writing directly to the deconflicted path instead of moving - const foreignRecord = this.queue.getSettledDocumentByPath(effectivePath); - const pathOccupiedByForeignDocument = - response.relativePath !== effectivePath && - foreignRecord !== undefined && - foreignRecord.documentId !== response.documentId; - if (pathOccupiedByForeignDocument) { - const actualPath = response.relativePath; - - if ("type" in response && response.type === "MergingUpdate") { - const responseBytes = base64ToBytes(response.contentBase64); - await this.operations.create(actualPath, responseBytes); - const afterWriteBytes = - await this.operations.read(actualPath); - const afterWriteHash = await hash(afterWriteBytes); - this.queue.setDocument(actualPath, { - documentId: response.documentId, - parentVersionId: response.vaultUpdateId, - remoteHash: afterWriteHash, - remoteRelativePath: response.relativePath - }); - await this.updateCache( - response.vaultUpdateId, - responseBytes, - actualPath - ); - } else { - await this.operations.create(actualPath, contentBytes); - this.queue.setDocument(actualPath, { - documentId: response.documentId, - parentVersionId: response.vaultUpdateId, - remoteHash: contentHash, - remoteRelativePath: response.relativePath - }); - await this.updateCache( - response.vaultUpdateId, - contentBytes, - actualPath - ); - } - } else { - await this.handleMaybeMergingResponse({ - path: effectivePath, - response, - contentHash, - originalContentBytes: contentBytes - }); - } - - this.queue.addSeenUpdateId(response.vaultUpdateId); + await this.handleMaybeMergingResponse({ + path: effectivePath, + response, + contentHash, + originalContentBytes: contentBytes + }); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -660,8 +350,6 @@ export class Syncer { private async processDelete( event: Extract ): Promise { - const { path } = event; - let documentId: DocumentId; if (typeof event.documentId === "string") { documentId = event.documentId; @@ -676,40 +364,21 @@ export class Syncer { } } - // For displacement deletes (side effect of a rename), check - // if another client updated the document since our last known - // version. If so, skip the delete to preserve their edits - if (event.displacedAtVersion !== undefined) { - const latest = await this.syncService.get({ documentId }); - if ( - !latest.isDeleted && - latest.vaultUpdateId > event.displacedAtVersion - ) { - this.logger.info( - `Skipping displacement delete for ${documentId} — document was updated by another client` - ); - return; - } - } - - // Use the document's current path from the store if available, - // otherwise fall back to the path from the event (e.g. when the - // document was displaced by a move and already removed from the store) const doc = this.queue.getDocumentByDocumentId(documentId); - const relativePath = doc?.path ?? path; + if (doc === undefined) { + this.logger.debug( + `Skipping delete for unknown documentId ${documentId}` + ); + return; + } + const relativePath = doc.path; const response = await this.syncService.delete({ documentId, relativePath }); - // Only remove the document record if it still belongs to this - // documentId; the path may have been reused by a different document - // (e.g. after a move-to-occupied-path) - if (doc !== undefined) { - this.queue.removeDocument(doc.path); - } - this.queue.addSeenUpdateId(response.vaultUpdateId); + this.queue.removeDocument(doc.path); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -781,7 +450,6 @@ export class Syncer { originalContentBytes: contentBytes }); - this.queue.addSeenUpdateId(response.vaultUpdateId); const isMerge = "type" in response && response.type === "MergingUpdate"; @@ -815,7 +483,6 @@ export class Syncer { this.logger.debug( `Document ${existingDoc.path} is already up-to-date` ); - this.queue.addSeenUpdateId(remoteVersion.vaultUpdateId); return; } @@ -831,7 +498,6 @@ export class Syncer { this.logger.debug( `Document ${remoteVersion.relativePath} has been deleted remotely, no need to sync` ); - this.queue.addSeenUpdateId(remoteVersion.vaultUpdateId); return; } @@ -858,13 +524,11 @@ export class Syncer { // Local changes survive; re-upload as a new document this.queue.removeDocument(currentPath); this.syncLocallyCreatedFile(currentPath); - this.queue.addSeenUpdateId(remoteVersion.vaultUpdateId); return; } await this.operations.delete(currentPath); this.queue.removeDocument(currentPath); - this.queue.addSeenUpdateId(remoteVersion.vaultUpdateId); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -897,7 +561,6 @@ export class Syncer { await this.operations.delete(currentPath); this.queue.removeDocument(currentPath); } - this.queue.addSeenUpdateId(fullVersion.vaultUpdateId); return; } @@ -920,7 +583,6 @@ export class Syncer { originalContentBytes: contentBytes }); - this.queue.addSeenUpdateId(response.vaultUpdateId); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -976,7 +638,6 @@ export class Syncer { responseBytes, actualPath ); - this.queue.addSeenUpdateId(fullVersion.vaultUpdateId); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -1058,7 +719,6 @@ export class Syncer { remoteVersion.relativePath ); - this.queue.addSeenUpdateId(remoteVersion.vaultUpdateId); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -1129,7 +789,6 @@ export class Syncer { const record = this.queue.getSettledDocumentByPath(path); if (record !== undefined && localHash !== record.remoteHash) { this.queue.removeDocument(path); - this.queue.addSeenUpdateId(response.vaultUpdateId); this.syncLocallyCreatedFile(path); return; } @@ -1156,12 +815,7 @@ export class Syncer { await this.operations.read(displacedPath); const displacedHash = await hash(displacedBytes); if (displacedHash !== displacedRecord.remoteHash) { - this.queue.enqueue({ - type: SyncEventType.SyncLocal, - documentId: displacedRecord.documentId, - path: displacedPath, - originalPath: displacedPath, - }); + this.queue.enqueue({ type: SyncEventType.SyncLocal, path: displacedPath }); } } } From 9183f30b5dea5ae547745d932d1da25de279a217 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 21 Apr 2026 20:01:28 +0100 Subject: [PATCH 03/52] fmt --- .../src/lib/types/DocumentUpdateMergedContent.ts | 9 +++++++++ .../src/lib/types/DocumentUpdateMetadata.ts | 9 +++++++++ .../src/lib/types/DocumentUpdateResponse.ts | 12 ++++++++---- .../src/lib/types/WebSocketServerMessage.ts | 3 ++- .../src/lib/types/WebSocketVaultPathChange.ts | 12 ++++++++++++ .../services/types/DocumentUpdateMergedContent.ts | 9 +++++++++ .../src/services/types/DocumentUpdateMetadata.ts | 9 +++++++++ .../src/services/types/DocumentUpdateResponse.ts | 12 ++++++++---- .../src/services/types/WebSocketServerMessage.ts | 3 ++- .../src/services/types/WebSocketVaultPathChange.ts | 12 ++++++++++++ .../sync-client/src/services/websocket-manager.ts | 2 +- .../src/sync-operations/offline-change-detector.ts | 2 +- .../src/sync-operations/sync-event-queue.ts | 2 +- frontend/sync-client/src/sync-operations/syncer.ts | 6 +++--- 14 files changed, 86 insertions(+), 16 deletions(-) create mode 100644 frontend/history-ui/src/lib/types/DocumentUpdateMergedContent.ts create mode 100644 frontend/history-ui/src/lib/types/DocumentUpdateMetadata.ts create mode 100644 frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts create mode 100644 frontend/sync-client/src/services/types/DocumentUpdateMergedContent.ts create mode 100644 frontend/sync-client/src/services/types/DocumentUpdateMetadata.ts create mode 100644 frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts diff --git a/frontend/history-ui/src/lib/types/DocumentUpdateMergedContent.ts b/frontend/history-ui/src/lib/types/DocumentUpdateMergedContent.ts new file mode 100644 index 00000000..5fca495f --- /dev/null +++ b/frontend/history-ui/src/lib/types/DocumentUpdateMergedContent.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Like [`DocumentVersion`] but without the `relative_path`. + * Used only in create/update responses when the server had to merge the + * client's content with a newer remote version and therefore must echo + * the merged content back. + */ +export type DocumentUpdateMergedContent = { vaultUpdateId: number, documentId: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }; diff --git a/frontend/history-ui/src/lib/types/DocumentUpdateMetadata.ts b/frontend/history-ui/src/lib/types/DocumentUpdateMetadata.ts new file mode 100644 index 00000000..393713fb --- /dev/null +++ b/frontend/history-ui/src/lib/types/DocumentUpdateMetadata.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Like [`DocumentVersionWithoutContent`] but without the `relative_path`. + * Used only in create/update responses where the client already tracks + * the path locally (the server is the source of truth for the + * document identity, not its path). + */ +export type DocumentUpdateMetadata = { vaultUpdateId: number, documentId: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, }; diff --git a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts b/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts index 418117e6..48f0fd1c 100644 --- a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts +++ b/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts @@ -1,8 +1,12 @@ // 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"; +import type { DocumentUpdateMergedContent } from "./DocumentUpdateMergedContent"; +import type { DocumentUpdateMetadata } from "./DocumentUpdateMetadata"; /** - * Response to an update document request. + * Response to a create/update document request. + * + * Neither variant contains `relative_path`: the client tracks the document's + * on-disk path locally and the server is the authority on document identity + * (`document_id`), not on its path. */ -export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion; +export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentUpdateMetadata | { "type": "MergingUpdate" } & DocumentUpdateMergedContent; diff --git a/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts b/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts index 45e37358..09bd3e86 100644 --- a/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts +++ b/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts @@ -1,5 +1,6 @@ // 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 { WebSocketVaultPathChange } from "./WebSocketVaultPathChange"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; -export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer; +export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "pathChange" } & WebSocketVaultPathChange | { "type": "cursorPositions" } & CursorPositionFromServer; diff --git a/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts b/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts new file mode 100644 index 00000000..5079b14b --- /dev/null +++ b/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A rename notification. Emitted whenever a write commits a document at + * a path that differs from what the origin client sent and/or from the + * document's previous stored path. Unlike [`WebSocketVaultUpdate`] this + * event is delivered to all subscribers *including the origin device*, + * because the create/update HTTP response no longer carries the path and + * the origin needs this event to learn the server-canonical path + * (e.g. when the server deduped or rejected a rename). + */ +export type WebSocketVaultPathChange = { vaultUpdateId: number, documentId: string, relativePath: string, }; diff --git a/frontend/sync-client/src/services/types/DocumentUpdateMergedContent.ts b/frontend/sync-client/src/services/types/DocumentUpdateMergedContent.ts new file mode 100644 index 00000000..4e0e4af4 --- /dev/null +++ b/frontend/sync-client/src/services/types/DocumentUpdateMergedContent.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Like [`DocumentVersion`] but without the `relative_path`. + * Used only in create/update responses when the server had to merge the + * client's content with a newer remote version and therefore must echo + * the merged content back. + */ +export interface DocumentUpdateMergedContent { vaultUpdateId: number, documentId: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, } diff --git a/frontend/sync-client/src/services/types/DocumentUpdateMetadata.ts b/frontend/sync-client/src/services/types/DocumentUpdateMetadata.ts new file mode 100644 index 00000000..325d896a --- /dev/null +++ b/frontend/sync-client/src/services/types/DocumentUpdateMetadata.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Like [`DocumentVersionWithoutContent`] but without the `relative_path`. + * Used only in create/update responses where the client already tracks + * the path locally (the server is the source of truth for the + * document identity, not its path). + */ +export interface DocumentUpdateMetadata { vaultUpdateId: number, documentId: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, } diff --git a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts index 418117e6..48f0fd1c 100644 --- a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts +++ b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts @@ -1,8 +1,12 @@ // 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"; +import type { DocumentUpdateMergedContent } from "./DocumentUpdateMergedContent"; +import type { DocumentUpdateMetadata } from "./DocumentUpdateMetadata"; /** - * Response to an update document request. + * Response to a create/update document request. + * + * Neither variant contains `relative_path`: the client tracks the document's + * on-disk path locally and the server is the authority on document identity + * (`document_id`), not on its path. */ -export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion; +export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentUpdateMetadata | { "type": "MergingUpdate" } & DocumentUpdateMergedContent; diff --git a/frontend/sync-client/src/services/types/WebSocketServerMessage.ts b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts index 45e37358..09bd3e86 100644 --- a/frontend/sync-client/src/services/types/WebSocketServerMessage.ts +++ b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts @@ -1,5 +1,6 @@ // 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 { WebSocketVaultPathChange } from "./WebSocketVaultPathChange"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; -export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer; +export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "pathChange" } & WebSocketVaultPathChange | { "type": "cursorPositions" } & CursorPositionFromServer; diff --git a/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts b/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts new file mode 100644 index 00000000..337eb135 --- /dev/null +++ b/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A rename notification. Emitted whenever a write commits a document at + * a path that differs from what the origin client sent and/or from the + * document's previous stored path. Unlike [`WebSocketVaultUpdate`] this + * event is delivered to all subscribers *including the origin device*, + * because the create/update HTTP response no longer carries the path and + * the origin needs this event to learn the server-canonical path + * (e.g. when the server deduped or rejected a rename). + */ +export interface WebSocketVaultPathChange { vaultUpdateId: number, documentId: string, relativePath: string, } diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index 5cceec72..9263142a 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -283,7 +283,7 @@ export class WebSocketManager { if (message.type === "vaultUpdate") { await this.onRemoteVaultUpdateReceived.triggerAsync(message); - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (message.type === "cursorPositions") { this.logger.debug( `Received cursor positions for ${JSON.stringify(message.clients)}` diff --git a/frontend/sync-client/src/sync-operations/offline-change-detector.ts b/frontend/sync-client/src/sync-operations/offline-change-detector.ts index 96b1126d..e6bc2b51 100644 --- a/frontend/sync-client/src/sync-operations/offline-change-detector.ts +++ b/frontend/sync-client/src/sync-operations/offline-change-detector.ts @@ -45,7 +45,7 @@ export async function scheduleOfflineChanges( const allDocuments = new Map(queue.allSettledDocuments()); const locallyRenamedPaths = enqueueRenamedDocuments(deps, allDocuments); - let deletedCandidates = await findLocallyDeletedFiles(operations, allDocuments); + const deletedCandidates = await findLocallyDeletedFiles(operations, allDocuments); const instructions = await buildSyncInstructions( deps, diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index b2cebb1f..9e10ba94 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -197,7 +197,7 @@ export class SyncEventQueue { e.documentId === docId) || (e.type === SyncEventType.SyncRemote && // we care about the local path not the remote - this.getDocumentByDocumentId(e.remoteVersion.documentId as DocumentId)?.path === path) + this.getDocumentByDocumentId(e.remoteVersion.documentId)?.path === path) ); } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 7a461b9c..17458cbe 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -199,9 +199,9 @@ export class Syncer { private async internalScheduleSyncForOfflineChanges(): Promise { await scheduleOfflineChanges( { logger: this.logger, operations: this.operations, queue: this.queue }, - (path) => this.syncLocallyCreatedFile(path), - (args) => this.syncLocallyUpdatedFile(args), - (path) => this.syncLocallyDeletedFile(path), + (path) => { this.syncLocallyCreatedFile(path); }, + (args) => { this.syncLocallyUpdatedFile(args); }, + (path) => { this.syncLocallyDeletedFile(path); }, ); await this.scheduleDrain(); From dca59a18dc04bb950d99eb2beb6ec74f63691ea7 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 21 Apr 2026 20:09:36 +0100 Subject: [PATCH 04/52] Add path change to server --- sync-server/src/app_state/database.rs | 73 +++++++++++++++---- sync-server/src/app_state/database/models.rs | 66 +++++++++++++++++ sync-server/src/app_state/websocket/models.rs | 10 +++ sync-server/src/server/create_document.rs | 19 ++++- sync-server/src/server/delete_document.rs | 17 ++++- sync-server/src/server/responses.rs | 9 ++- .../src/server/restore_document_version.rs | 37 +++++++++- sync-server/src/server/update_document.rs | 20 ++++- sync-server/src/server/websocket.rs | 3 +- 9 files changed, 225 insertions(+), 29 deletions(-) diff --git a/sync-server/src/app_state/database.rs b/sync-server/src/app_state/database.rs index c91bc28a..b9bf8df1 100644 --- a/sync-server/src/app_state/database.rs +++ b/sync-server/src/app_state/database.rs @@ -16,6 +16,24 @@ pub mod models; #[error("Database is busy")] pub struct WriteBusyError; +/// Tells [`Database::insert_document_version`] which WebSocket events the +/// just-committed version should produce. The caller is the only party +/// with enough context to decide this (the DB layer has no access to +/// "what the client sent" or "what the prior version looked like"). +#[derive(Debug, Clone, Copy, Default)] +pub struct InsertBroadcast { + /// Emit a `VaultUpdate` (filtered from the origin device). Set when + /// the stored bytes differ from the prior version's bytes — i.e. + /// peers need to pull new content. + pub content_changed: bool, + + /// Emit a `PathChange` (delivered to every client, origin included). + /// Set when the stored path differs from the prior stored path *or* + /// from the path the origin client sent — i.e. someone needs to + /// reconcile a dedupe, rename, or first-rename-wins outcome. + pub path_changed: bool, +} + use sqlx::{ Pool, Sqlite, pool::PoolConnection, sqlite::SqliteConnection, sqlite::SqlitePoolOptions, }; @@ -25,7 +43,10 @@ use uuid::fmt::Hyphenated; use super::websocket::{ broadcasts::Broadcasts, - models::{WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultUpdate}, + models::{ + WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultPathChange, + WebSocketVaultUpdate, + }, }; use crate::config::database_config::DatabaseConfig; use crate::consts::IDLE_POOL_TIMEOUT; @@ -669,6 +690,7 @@ impl Database { vault_id: &VaultId, version: &StoredDocumentVersion, mut transaction: WriteTransaction, + broadcast: InsertBroadcast, ) -> Result<()> { let document_id = version.document_id.as_hyphenated(); let query = sqlx::query!( @@ -712,18 +734,43 @@ impl Database { .await .context("Failed to commit transaction")?; - self.broadcasts - .send_document_update( - vault_id.clone(), - WebSocketServerMessageWithOrigin::with_origin( - version.device_id.clone(), - WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { - documents: vec![version.clone().into()], - is_initial_sync: false, - }), - ), - ) - .await; + if broadcast.content_changed { + // Content events are filtered out for the origin device — the + // origin already has the content (or learns about the merge + // via the HTTP response). + self.broadcasts + .send_document_update( + vault_id.clone(), + WebSocketServerMessageWithOrigin::with_origin( + version.device_id.clone(), + WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { + documents: vec![version.clone().into()], + is_initial_sync: false, + }), + ), + ) + .await; + } + + if broadcast.path_changed { + // Path change events intentionally carry no origin so *every* + // connected client (including the one that made the write) + // receives them. The create/update HTTP response no longer + // carries `relative_path`, so the origin device relies on this + // event to learn the server-canonical path. + self.broadcasts + .send_document_update( + vault_id.clone(), + WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::PathChange( + WebSocketVaultPathChange { + vault_update_id: version.vault_update_id, + document_id: version.document_id, + relative_path: version.relative_path.clone(), + }, + )), + ) + .await; + } Ok(()) } diff --git a/sync-server/src/app_state/database/models.rs b/sync-server/src/app_state/database/models.rs index 7aea3358..9812703d 100644 --- a/sync-server/src/app_state/database/models.rs +++ b/sync-server/src/app_state/database/models.rs @@ -77,6 +77,72 @@ pub struct DocumentVersion { pub device_id: DeviceId, } +/// Like [`DocumentVersionWithoutContent`] but without the `relative_path`. +/// Used only in create/update responses where the client already tracks +/// the path locally (the server is the source of truth for the +/// document identity, not its path). +#[derive(TS, Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentUpdateMetadata { + #[ts(type = "number")] + pub vault_update_id: VaultUpdateId, + + pub document_id: DocumentId, + pub updated_date: DateTime, + pub is_deleted: bool, + pub user_id: UserId, + pub device_id: DeviceId, + + #[ts(type = "number")] + pub content_size: u64, +} + +impl From for DocumentUpdateMetadata { + fn from(value: StoredDocumentVersion) -> Self { + Self { + vault_update_id: value.vault_update_id, + document_id: value.document_id, + updated_date: value.updated_date, + is_deleted: value.is_deleted, + user_id: value.user_id, + device_id: value.device_id, + content_size: value.content.len() as u64, + } + } +} + +/// Like [`DocumentVersion`] but without the `relative_path`. +/// Used only in create/update responses when the server had to merge the +/// client's content with a newer remote version and therefore must echo +/// the merged content back. +#[derive(TS, Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentUpdateMergedContent { + #[ts(type = "number")] + pub vault_update_id: VaultUpdateId, + + pub document_id: DocumentId, + pub updated_date: DateTime, + pub content_base64: String, + pub is_deleted: bool, + pub user_id: UserId, + pub device_id: DeviceId, +} + +impl From for DocumentUpdateMergedContent { + fn from(value: StoredDocumentVersion) -> Self { + Self { + vault_update_id: value.vault_update_id, + document_id: value.document_id, + updated_date: value.updated_date, + content_base64: STANDARD.encode(&value.content), + is_deleted: value.is_deleted, + user_id: value.user_id, + device_id: value.device_id, + } + } +} + /// Row struct for vault history queries (used by `sqlx::query_as!`) #[derive(Debug)] pub struct VaultHistoryRow { diff --git a/sync-server/src/app_state/websocket/models.rs b/sync-server/src/app_state/websocket/models.rs index 116c2b84..97247229 100644 --- a/sync-server/src/app_state/websocket/models.rs +++ b/sync-server/src/app_state/websocket/models.rs @@ -64,6 +64,15 @@ pub struct WebSocketVaultUpdate { pub is_initial_sync: bool, } +#[derive(TS, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct WebSocketVaultPathChange { + #[ts(type = "number")] + pub vault_update_id: VaultUpdateId, + pub document_id: DocumentId, + pub relative_path: String, +} + #[derive(TS, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase", tag = "type")] #[ts(export)] @@ -77,6 +86,7 @@ pub enum WebSocketClientMessage { #[ts(export)] pub enum WebSocketServerMessage { VaultUpdate(WebSocketVaultUpdate), + PathChange(WebSocketVaultPathChange), CursorPositions(CursorPositionFromServer), } diff --git a/sync-server/src/server/create_document.rs b/sync-server/src/server/create_document.rs index 89941b9a..51ed5b47 100644 --- a/sync-server/src/server/create_document.rs +++ b/sync-server/src/server/create_document.rs @@ -11,7 +11,10 @@ use super::{device_id_header::DeviceIdHeader, requests::CreateDocumentVersion}; use crate::{ app_state::{ AppState, - database::models::{StoredDocumentVersion, VaultId}, + database::{ + InsertBroadcast, + models::{StoredDocumentVersion, VaultId}, + }, }, config::user_config::User, errors::{SyncServerError, client_error, server_error, write_transaction_error}, @@ -116,6 +119,8 @@ pub async fn create_document( ); } + let path_changed = deduped_path != sanitized_relative_path; + let new_version = StoredDocumentVersion { vault_update_id: last_update_id + 1, document_id, @@ -130,7 +135,17 @@ pub async fn create_document( state .database - .insert_document_version(&vault_id, &new_version, transaction) + .insert_document_version( + &vault_id, + &new_version, + transaction, + InsertBroadcast { + // A brand-new document is always a content change for peers. + content_changed: true, + // Origin needs to know if the server deduped its requested path. + path_changed, + }, + ) .await .map_err(server_error)?; diff --git a/sync-server/src/server/delete_document.rs b/sync-server/src/server/delete_document.rs index 3e6398b8..48872e34 100644 --- a/sync-server/src/server/delete_document.rs +++ b/sync-server/src/server/delete_document.rs @@ -11,8 +11,9 @@ use super::{device_id_header::DeviceIdHeader, requests::DeleteDocumentVersion}; use crate::{ app_state::{ AppState, - database::models::{ - DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, + database::{ + InsertBroadcast, + models::{DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId}, }, }, config::user_config::User, @@ -91,7 +92,17 @@ pub async fn delete_document( state .database - .insert_document_version(&vault_id, &new_version, transaction) + .insert_document_version( + &vault_id, + &new_version, + transaction, + InsertBroadcast { + // Deletion is a content change peers must learn about. + content_changed: true, + // Delete never renames. + path_changed: false, + }, + ) .await .map_err(server_error)?; diff --git a/sync-server/src/server/responses.rs b/sync-server/src/server/responses.rs index f393747d..18158e65 100644 --- a/sync-server/src/server/responses.rs +++ b/sync-server/src/server/responses.rs @@ -3,7 +3,8 @@ use serde::{self, Serialize}; use ts_rs::TS; use crate::app_state::database::models::{ - DocumentVersion, DocumentVersionWithoutContent, VaultUpdateId, + DocumentUpdateMergedContent, DocumentUpdateMetadata, DocumentVersionWithoutContent, + VaultUpdateId, }; /// Response to a ping request. @@ -66,7 +67,7 @@ pub struct ListVaultsResponse { pub user_name: String, } -/// Response to an update document request. +/// Response to a create/update document request. #[derive(TS, Debug, Clone, Serialize)] #[serde(tag = "type")] #[ts(export)] @@ -74,9 +75,9 @@ pub enum DocumentUpdateResponse { /// Returned when the created/updated document's content is the same as was /// sent in the create/update request and thus the response doesn't contain /// the content because the client must already have it. - FastForwardUpdate(DocumentVersionWithoutContent), + FastForwardUpdate(DocumentUpdateMetadata), /// Returned when the created/updated document's content is different from /// what was sent in the create/update request. - MergingUpdate(DocumentVersion), + MergingUpdate(DocumentUpdateMergedContent), } diff --git a/sync-server/src/server/restore_document_version.rs b/sync-server/src/server/restore_document_version.rs index 36c0344e..fadbdb5a 100644 --- a/sync-server/src/server/restore_document_version.rs +++ b/sync-server/src/server/restore_document_version.rs @@ -11,9 +11,12 @@ use super::device_id_header::DeviceIdHeader; use crate::{ app_state::{ AppState, - database::models::{ - DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, - VaultUpdateId, + database::{ + InsertBroadcast, + models::{ + DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, + VaultUpdateId, + }, }, }, config::user_config::User, @@ -120,6 +123,14 @@ pub async fn restore_document_version( .await .map_err(server_error)?; + // The current latest (pre-restore) is our baseline for deciding + // whether content and/or path actually change. + let current_latest = state + .database + .get_latest_document(&vault_id, &document_id, Some(&mut *transaction)) + .await + .map_err(server_error)?; + let new_version = StoredDocumentVersion { vault_update_id: last_update_id + 1, document_id, @@ -132,9 +143,27 @@ pub async fn restore_document_version( has_been_merged: false, }; + let (content_changed, path_changed) = match ¤t_latest { + Some(prev) => ( + prev.content != new_version.content || prev.is_deleted, + prev.relative_path != new_version.relative_path, + ), + // No prior version (shouldn't happen in practice — target_version + // already proved the document exists — but treat defensively). + None => (true, true), + }; + state .database - .insert_document_version(&vault_id, &new_version, transaction) + .insert_document_version( + &vault_id, + &new_version, + transaction, + InsertBroadcast { + content_changed, + path_changed, + }, + ) .await .map_err(server_error)?; diff --git a/sync-server/src/server/update_document.rs b/sync-server/src/server/update_document.rs index b6227de5..998c2dd4 100644 --- a/sync-server/src/server/update_document.rs +++ b/sync-server/src/server/update_document.rs @@ -17,7 +17,7 @@ use crate::{ app_state::{ AppState, database::{ - WriteTransaction, + InsertBroadcast, WriteTransaction, models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId}, }, }, @@ -292,6 +292,14 @@ pub async fn update_document( latest_version.relative_path.clone() }; + let content_changed = merged_content != latest_version.content; + // Stored path differs from either the prior stored path (peers need + // to learn about the rename) or from the path the origin sent + // (origin needs to learn if its rename was deduped or rejected by + // first-rename-wins). + let path_changed = new_relative_path != latest_version.relative_path + || new_relative_path != sanitized_relative_path; + let new_version = StoredDocumentVersion { document_id, vault_update_id: last_update_id + 1, @@ -306,7 +314,15 @@ pub async fn update_document( state .database - .insert_document_version(&vault_id, &new_version, transaction) + .insert_document_version( + &vault_id, + &new_version, + transaction, + InsertBroadcast { + content_changed, + path_changed, + }, + ) .await .map_err(server_error)?; diff --git a/sync-server/src/server/websocket.rs b/sync-server/src/server/websocket.rs index a0d15c10..ffac8d38 100644 --- a/sync-server/src/server/websocket.rs +++ b/sync-server/src/server/websocket.rs @@ -179,7 +179,8 @@ async fn websocket( .filter(|client| client.device_id != device_id) .collect(), }), - WebSocketServerMessage::VaultUpdate(_) => update.message, + WebSocketServerMessage::VaultUpdate(_) + | WebSocketServerMessage::PathChange(_) => update.message, }; send_update_over_websocket(&message, &mut sender).await?; From 5ee9db00076b4c367e209b248cf715f74741d333 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 21 Apr 2026 20:30:04 +0100 Subject: [PATCH 05/52] store creation id and implement moves --- .../src/services/websocket-manager.ts | 43 ++++++++----- .../sync-client/src/sync-operations/syncer.ts | 61 +++++++++++++++++++ sync-server/src/app_state/database.rs | 7 ++- ...421000000_add_creation_vault_update_id.sql | 20 ++++++ sync-server/src/app_state/database/models.rs | 1 + sync-server/src/server/create_document.rs | 4 +- sync-server/src/server/delete_document.rs | 13 ++-- .../src/server/restore_document_version.rs | 1 + sync-server/src/server/update_document.rs | 1 + 9 files changed, 128 insertions(+), 23 deletions(-) create mode 100644 sync-server/src/app_state/database/migrations/20260421000000_add_creation_vault_update_id.sql diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index 9263142a..5ec40e49 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -5,6 +5,7 @@ import type { WebSocketClientMessage } from "./types/WebSocketClientMessage"; import type { CursorPositionFromClient } from "./types/CursorPositionFromClient"; import type { ClientCursors } from "./types/ClientCursors"; import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate"; +import type { WebSocketVaultPathChange } from "./types/WebSocketVaultPathChange"; import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS, WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS @@ -22,6 +23,10 @@ export class WebSocketManager { (update: WebSocketVaultUpdate) => Promise >(); + public readonly onRemotePathChangeReceived = new EventListeners< + (pathChange: WebSocketVaultPathChange) => Promise + >(); + public readonly onRemoteCursorsUpdateReceived = new EventListeners< (cursors: ClientCursors[]) => Promise >(); @@ -280,22 +285,28 @@ export class WebSocketManager { private async handleWebSocketMessage( message: WebSocketServerMessage ): Promise { - if (message.type === "vaultUpdate") { - await this.onRemoteVaultUpdateReceived.triggerAsync(message); - - - } else if (message.type === "cursorPositions") { - this.logger.debug( - `Received cursor positions for ${JSON.stringify(message.clients)}` - ); - - await this.onRemoteCursorsUpdateReceived.triggerAsync( - message.clients - ); - } else { - this.logger.warn( - `Received unknown message type: ${JSON.stringify(message)}` - ); + switch (message.type) { + case "vaultUpdate": + await this.onRemoteVaultUpdateReceived.triggerAsync(message); + return; + case "pathChange": + this.logger.debug( + `Received path change for document ${message.documentId} → ${message.relativePath}` + ); + await this.onRemotePathChangeReceived.triggerAsync(message); + return; + case "cursorPositions": + this.logger.debug( + `Received cursor positions for ${JSON.stringify(message.clients)}` + ); + await this.onRemoteCursorsUpdateReceived.triggerAsync( + message.clients + ); + return; + default: + this.logger.warn( + `Received unknown message type: ${JSON.stringify(message)}` + ); } } } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 17458cbe..7e772432 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -14,6 +14,7 @@ import { scheduleOfflineChanges } from "./offline-change-detector"; import { SyncResetError } from "../errors/sync-reset-error"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate"; +import type { WebSocketVaultPathChange } from "../services/types/WebSocketVaultPathChange"; import type { WebSocketManager } from "../services/websocket-manager"; import type { WebSocketClientMessage } from "../services/types/WebSocketClientMessage"; import { EventListeners } from "../utils/data-structures/event-listeners"; @@ -73,6 +74,9 @@ export class Syncer { this.webSocketManager.onRemoteVaultUpdateReceived.add( this.syncRemotelyUpdatedFile.bind(this) ); + this.webSocketManager.onRemotePathChangeReceived.add( + this.syncRemotelyChangedPath.bind(this) + ); } public get isFirstSyncComplete(): boolean { @@ -173,6 +177,63 @@ export class Syncer { } } + // A PathChange notifies us that a document now lives at a new server- + // canonical path. It's delivered to every client (origin included) + // because the create/update HTTP response no longer carries the path, + // so the only way the origin learns about dedupe or first-rename-wins + // is via this event. + public async syncRemotelyChangedPath( + pathChange: WebSocketVaultPathChange + ): Promise { + try { + const existing = this.queue.getDocumentByDocumentId( + pathChange.documentId + ); + if (existing === undefined) { + throw new Error( + `Received path change for unknown document ${pathChange.documentId}` + ); + } + + const { path: currentPath, record } = existing; + const newPath = pathChange.relativePath; + + if (currentPath !== newPath) { + await this.operations.move(currentPath, newPath); + + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: { + type: SyncType.MOVE, + relativePath: newPath, + movedFrom: currentPath + }, + message: "Applied remote path change" + }); + } + + // `operations.move` updates the queue's path index, but + // doesn't touch `remoteRelativePath`. Refresh it so offline + // change detection compares against the server's path. + // parentVersionId intentionally stays at its prior value: + // if the write also changed content, the corresponding + // VaultUpdate handles that; advancing it here would make us + // skip fetching content we don't yet have. + this.queue.setDocument(newPath, { + ...record, + remoteRelativePath: newPath + }); + } catch (e) { + if (e instanceof SyncResetError) { + this.logger.info( + "Failed to apply remote path change due to a reset" + ); + return; + } + this.logger.error(`Failed to apply remote path change: ${e}`); + } + } + public reset(): void { this._isFirstSyncComplete = false; this.queue.clear(); diff --git a/sync-server/src/app_state/database.rs b/sync-server/src/app_state/database.rs index b9bf8df1..a249dadc 100644 --- a/sync-server/src/app_state/database.rs +++ b/sync-server/src/app_state/database.rs @@ -586,6 +586,7 @@ impl Database { r#" select vault_update_id, + creation_vault_update_id, document_id as "document_id: Hyphenated", relative_path, updated_date as "updated_date: chrono::DateTime", @@ -626,6 +627,7 @@ impl Database { r#" select vault_update_id, + creation_vault_update_id, document_id as "document_id: Hyphenated", relative_path, updated_date as "updated_date: chrono::DateTime", @@ -661,6 +663,7 @@ impl Database { r#" select vault_update_id, + creation_vault_update_id, document_id as "document_id: Hyphenated", relative_path, updated_date as "updated_date: chrono::DateTime", @@ -697,6 +700,7 @@ impl Database { r#" insert into documents ( vault_update_id, + creation_vault_update_id, document_id, relative_path, updated_date, @@ -706,9 +710,10 @@ impl Database { device_id, has_been_merged ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, version.vault_update_id, + version.creation_vault_update_id, document_id, version.relative_path, version.updated_date, diff --git a/sync-server/src/app_state/database/migrations/20260421000000_add_creation_vault_update_id.sql b/sync-server/src/app_state/database/migrations/20260421000000_add_creation_vault_update_id.sql new file mode 100644 index 00000000..40dc85fb --- /dev/null +++ b/sync-server/src/app_state/database/migrations/20260421000000_add_creation_vault_update_id.sql @@ -0,0 +1,20 @@ +ALTER TABLE documents ADD COLUMN creation_vault_update_id INTEGER NOT NULL DEFAULT 0; + +UPDATE documents +SET creation_vault_update_id = ( + SELECT MIN(d2.vault_update_id) + FROM documents d2 + WHERE d2.document_id = documents.document_id +); + +DROP VIEW latest_document_versions; + +CREATE VIEW IF NOT EXISTS latest_document_versions AS --recreate view as it now includes one more field +SELECT d.* +FROM documents d +INNER JOIN ( + SELECT MAX(vault_update_id) AS max_version_id + FROM documents + GROUP BY document_id +) max_versions +ON d.vault_update_id = max_versions.max_version_id; diff --git a/sync-server/src/app_state/database/models.rs b/sync-server/src/app_state/database/models.rs index 9812703d..80b628e8 100644 --- a/sync-server/src/app_state/database/models.rs +++ b/sync-server/src/app_state/database/models.rs @@ -13,6 +13,7 @@ pub type DeviceId = String; #[derive(Debug, Clone)] pub struct StoredDocumentVersion { pub vault_update_id: VaultUpdateId, + pub creation_vault_update_id: VaultUpdateId, pub document_id: DocumentId, pub relative_path: String, pub updated_date: DateTime, diff --git a/sync-server/src/server/create_document.rs b/sync-server/src/server/create_document.rs index 51ed5b47..7f0f6b60 100644 --- a/sync-server/src/server/create_document.rs +++ b/sync-server/src/server/create_document.rs @@ -121,8 +121,10 @@ pub async fn create_document( let path_changed = deduped_path != sanitized_relative_path; + let new_vault_update_id = last_update_id + 1; let new_version = StoredDocumentVersion { - vault_update_id: last_update_id + 1, + vault_update_id: new_vault_update_id, + creation_vault_update_id: new_vault_update_id, document_id, relative_path: deduped_path, content: new_content, diff --git a/sync-server/src/server/delete_document.rs b/sync-server/src/server/delete_document.rs index 48872e34..a523c499 100644 --- a/sync-server/src/server/delete_document.rs +++ b/sync-server/src/server/delete_document.rs @@ -73,13 +73,16 @@ pub async fn delete_document( return Ok(Json(latest_version.clone().into())); } - let (latest_relative_path, latest_content) = latest_version.map_or_else( - || (String::new(), Vec::new()), - |version| (version.relative_path, version.content), - ); + let new_vault_update_id = last_update_id + 1; + let (latest_relative_path, latest_content, creation_vault_update_id) = + latest_version.map_or_else( + || (String::new(), Vec::new(), new_vault_update_id), + |version| (version.relative_path, version.content, version.creation_vault_update_id), + ); let new_version = StoredDocumentVersion { - vault_update_id: last_update_id + 1, + vault_update_id: new_vault_update_id, + creation_vault_update_id, document_id, relative_path: latest_relative_path, content: latest_content, // copy the content from the latest version diff --git a/sync-server/src/server/restore_document_version.rs b/sync-server/src/server/restore_document_version.rs index fadbdb5a..bb4e6775 100644 --- a/sync-server/src/server/restore_document_version.rs +++ b/sync-server/src/server/restore_document_version.rs @@ -133,6 +133,7 @@ pub async fn restore_document_version( let new_version = StoredDocumentVersion { vault_update_id: last_update_id + 1, + creation_vault_update_id: target_version.creation_vault_update_id, document_id, relative_path: restore_path, content: target_version.content, diff --git a/sync-server/src/server/update_document.rs b/sync-server/src/server/update_document.rs index 998c2dd4..1963310a 100644 --- a/sync-server/src/server/update_document.rs +++ b/sync-server/src/server/update_document.rs @@ -303,6 +303,7 @@ pub async fn update_document( let new_version = StoredDocumentVersion { document_id, vault_update_id: last_update_id + 1, + creation_vault_update_id: latest_version.creation_vault_update_id, relative_path: new_relative_path, content: merged_content, updated_date: chrono::Utc::now(), From 6a8c7635f13ce59e8193b7dd85bcaf1069dd25ac Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 21 Apr 2026 22:35:30 +0100 Subject: [PATCH 06/52] looks ok --- sync-server/src/app_state/cursors.rs | 18 +++--- sync-server/src/app_state/database.rs | 50 +++++++++-------- .../src/app_state/websocket/broadcasts.rs | 35 +++++++++--- sync-server/src/server/create_document.rs | 55 +++++++++++-------- sync-server/src/server/delete_document.rs | 29 ++++++---- sync-server/src/server/requests.rs | 3 + .../src/server/restore_document_version.rs | 9 ++- sync-server/src/server/websocket.rs | 1 - 8 files changed, 122 insertions(+), 78 deletions(-) diff --git a/sync-server/src/app_state/cursors.rs b/sync-server/src/app_state/cursors.rs index 4d01995a..e17fb4f7 100644 --- a/sync-server/src/app_state/cursors.rs +++ b/sync-server/src/app_state/cursors.rs @@ -117,16 +117,14 @@ impl Cursors { .unwrap_or_default() }; - self.broadcasts - .send_document_update( - vault_id.clone(), - WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::CursorPositions( - CursorPositionFromServer { - clients: client_cursors, - }, - )), - ) - .await; + self.broadcasts.send_document_update( + vault_id.clone(), + WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::CursorPositions( + CursorPositionFromServer { + clients: client_cursors, + }, + )), + ); } pub async fn remove_cursors_of_device(&self, vault_id: &VaultId, device_id: &DeviceId) { diff --git a/sync-server/src/app_state/database.rs b/sync-server/src/app_state/database.rs index a249dadc..e8c02b31 100644 --- a/sync-server/src/app_state/database.rs +++ b/sync-server/src/app_state/database.rs @@ -739,22 +739,23 @@ impl Database { .await .context("Failed to commit transaction")?; + // Both sends are synchronous: there's no `.await` between the + // `commit()` above and function return, so a task cancellation + // can't drop the broadcast and leave peers permanently behind. if broadcast.content_changed { // Content events are filtered out for the origin device — the // origin already has the content (or learns about the merge // via the HTTP response). - self.broadcasts - .send_document_update( - vault_id.clone(), - WebSocketServerMessageWithOrigin::with_origin( - version.device_id.clone(), - WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { - documents: vec![version.clone().into()], - is_initial_sync: false, - }), - ), - ) - .await; + self.broadcasts.send_document_update( + vault_id.clone(), + WebSocketServerMessageWithOrigin::with_origin( + version.device_id.clone(), + WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { + documents: vec![version.clone().into()], + is_initial_sync: false, + }), + ), + ); } if broadcast.path_changed { @@ -763,18 +764,19 @@ impl Database { // receives them. The create/update HTTP response no longer // carries `relative_path`, so the origin device relies on this // event to learn the server-canonical path. - self.broadcasts - .send_document_update( - vault_id.clone(), - WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::PathChange( - WebSocketVaultPathChange { - vault_update_id: version.vault_update_id, - document_id: version.document_id, - relative_path: version.relative_path.clone(), - }, - )), - ) - .await; + self.broadcasts.send_document_update( + vault_id.clone(), + WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::PathChange( + WebSocketVaultPathChange { + vault_update_id: version.vault_update_id, + document_id: version.document_id, + relative_path: version.relative_path.clone(), + updated_date: version.updated_date, + user_id: version.user_id.clone(), + device_id: version.device_id.clone(), + }, + )), + ); } Ok(()) diff --git a/sync-server/src/app_state/websocket/broadcasts.rs b/sync-server/src/app_state/websocket/broadcasts.rs index 91183970..0b49fa27 100644 --- a/sync-server/src/app_state/websocket/broadcasts.rs +++ b/sync-server/src/app_state/websocket/broadcasts.rs @@ -1,4 +1,7 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex as StdMutex}, +}; use log::{debug, warn}; use tokio::sync::{Mutex, broadcast}; @@ -9,7 +12,12 @@ use crate::{app_state::database::models::VaultId, config::server_config::ServerC #[derive(Debug, Clone)] pub struct Broadcasts { broadcast_channel_capacity: usize, - tx: Arc>>>, + // `tx` uses a blocking std::sync::Mutex because the critical section is + // a HashMap lookup plus a synchronous `broadcast::Sender::send`. Making + // this non-async lets `send_document_update` run without an `.await`, + // so an axum handler that is cancelled between `transaction.commit()` + // and the broadcast can never drop the notification mid-flight. + tx: Arc>>>, send_locks: Arc>>>>, } @@ -19,7 +27,7 @@ impl Broadcasts { pub fn new(server_config: &ServerConfig) -> Self { Self { broadcast_channel_capacity: server_config.broadcast_channel_capacity, - tx: Arc::new(Mutex::new(HashMap::new())), + tx: Arc::new(StdMutex::new(HashMap::new())), send_locks: Arc::new(Mutex::new(HashMap::new())), } } @@ -42,19 +50,25 @@ impl Broadcasts { tx_map.retain(|_, sender| sender.receiver_count() > 0); } - pub async fn get_receiver( + pub fn get_receiver( &self, vault: VaultId, max_clients: usize, ) -> Result, crate::errors::SyncServerError> { - let mut tx_map = self.tx.lock().await; + let mut tx_map = self + .tx + .lock() + .expect("broadcasts.tx mutex poisoned — a previous holder panicked"); Self::prune_inactive_vaults(&mut tx_map); let sender = tx_map .entry(vault) .or_insert_with(|| broadcast::channel(self.broadcast_channel_capacity).0); + // Hold the lock across the count check *and* the subscribe so the + // `max_clients` cap is atomic: two concurrent callers can't both + // observe `receiver_count() < max_clients` and both subscribe. if sender.receiver_count() >= max_clients { return Err(crate::errors::client_error(anyhow::anyhow!( "Vault has reached the maximum number of clients ({max_clients})" @@ -65,13 +79,18 @@ impl Broadcasts { } /// Notify all clients (who are subscribed to the vault) about an update. - /// We only log failures and don't propagate them. - pub async fn send_document_update( + /// Synchronous: safe to invoke from a handler between `commit()` and + /// function return without worrying about task cancellation dropping + /// the broadcast mid-flight. Failures are logged, never propagated. + pub fn send_document_update( &self, vault: VaultId, document: WebSocketServerMessageWithOrigin, ) { - let mut tx_map = self.tx.lock().await; + let mut tx_map = self + .tx + .lock() + .expect("broadcasts.tx mutex poisoned — a previous holder panicked"); Self::prune_inactive_vaults(&mut tx_map); let sender = tx_map diff --git a/sync-server/src/server/create_document.rs b/sync-server/src/server/create_document.rs index 7f0f6b60..64c3c5fe 100644 --- a/sync-server/src/server/create_document.rs +++ b/sync-server/src/server/create_document.rs @@ -69,31 +69,40 @@ pub async fn create_document( .map_err(server_error)?; if let Some(latest_version) = latest_version { - let is_mergeable_text = is_file_type_mergable( - &sanitized_relative_path, - &state.config.server.mergeable_file_extensions, - ) && !is_binary(&latest_version.content) - && !is_binary(&new_content); - - if is_mergeable_text || new_content == latest_version.content { - return update_document::update_document( + // Only merge with an existing document the client couldn't have + // known about: its creation is newer than the client's last seen + // vault update to avoid creating cycles by merging two documents into one. + // This could happen if both clients know of document A at path P1, + // but client 2 moves it to P2 while client 1 creates a new document at P2, + // then client 1 would merge its new document with the moved version of A at P2 + // that client 2 resulting in two files (P1 and P2) with the same doc id (A). + if latest_version.creation_vault_update_id > request.last_seen_vault_update_id { + let is_mergeable_text = is_file_type_mergable( &sanitized_relative_path, - Vec::new(), - vault_id, - latest_version.document_id, - &request.relative_path, - new_content, - user, - device_id, - state, - transaction, - ) - .await; - } + &state.config.server.mergeable_file_extensions, + ) && !is_binary(&latest_version.content) + && !is_binary(&new_content); - // For non-mergeable (binary) files with different content, don't - // merge, create a separate document at a deconflicted path so - // neither client's data is silently overwritten. + if is_mergeable_text || new_content == latest_version.content { + return update_document::update_document( + &sanitized_relative_path, + Vec::new(), + vault_id, + latest_version.document_id, + &request.relative_path, + new_content, + user, + device_id, + state, + transaction, + ) + .await; + } + + // For non-mergeable (binary) files with different content, don't + // merge, create a separate document at a deconflicted path so + // neither client's data is silently overwritten. + } } let document_id = uuid::Uuid::new_v4(); diff --git a/sync-server/src/server/delete_document.rs b/sync-server/src/server/delete_document.rs index a523c499..3057bd6e 100644 --- a/sync-server/src/server/delete_document.rs +++ b/sync-server/src/server/delete_document.rs @@ -1,4 +1,4 @@ -use anyhow::Context; +use anyhow::{Context, anyhow}; use axum::{ Extension, Json, extract::{Path, State}, @@ -17,7 +17,7 @@ use crate::{ }, }, config::user_config::User, - errors::{SyncServerError, server_error, write_transaction_error}, + errors::{SyncServerError, not_found_error, server_error, write_transaction_error}, utils::normalize::normalize, }; @@ -60,9 +60,18 @@ pub async fn delete_document( .await .map_err(server_error)?; - if let Some(latest_version) = &latest_version - && latest_version.is_deleted - { + let Some(latest_version) = latest_version else { + transaction + .rollback() + .await + .context("Failed to roll back transaction") + .map_err(server_error)?; + return Err(not_found_error(anyhow!( + "Document `{document_id}` not found in vault `{vault_id}`" + ))); + }; + + if latest_version.is_deleted { transaction .rollback() .await @@ -70,15 +79,13 @@ pub async fn delete_document( .map_err(server_error)?; info!("Document `{document_id}` has already been deleted",); - return Ok(Json(latest_version.clone().into())); + return Ok(Json(latest_version.into())); } let new_vault_update_id = last_update_id + 1; - let (latest_relative_path, latest_content, creation_vault_update_id) = - latest_version.map_or_else( - || (String::new(), Vec::new(), new_vault_update_id), - |version| (version.relative_path, version.content, version.creation_vault_update_id), - ); + let latest_relative_path = latest_version.relative_path; + let latest_content = latest_version.content; + let creation_vault_update_id = latest_version.creation_vault_update_id; let new_version = StoredDocumentVersion { vault_update_id: new_vault_update_id, diff --git a/sync-server/src/server/requests.rs b/sync-server/src/server/requests.rs index 107c998c..250c65d7 100644 --- a/sync-server/src/server/requests.rs +++ b/sync-server/src/server/requests.rs @@ -11,6 +11,9 @@ use crate::app_state::database::models::VaultUpdateId; pub struct CreateDocumentVersion { pub relative_path: String, + #[ts(type = "number")] + pub last_seen_vault_update_id: VaultUpdateId, + #[ts(as = "Vec")] #[form_data(limit = "unlimited")] pub content: FieldData, diff --git a/sync-server/src/server/restore_document_version.rs b/sync-server/src/server/restore_document_version.rs index bb4e6775..7522e73c 100644 --- a/sync-server/src/server/restore_document_version.rs +++ b/sync-server/src/server/restore_document_version.rs @@ -147,7 +147,14 @@ pub async fn restore_document_version( let (content_changed, path_changed) = match ¤t_latest { Some(prev) => ( prev.content != new_version.content || prev.is_deleted, - prev.relative_path != new_version.relative_path, + // Mirror `update_document`: `path_changed` is true when the + // stored path differs from either the prior stored path (peers + // need to learn about the move) *or* from the path the caller + // implicitly requested (`target_version.relative_path`, so the + // origin learns if the server deduped its requested restore + // path). + prev.relative_path != new_version.relative_path + || target_version.relative_path != new_version.relative_path, ), // No prior version (shouldn't happen in practice — target_version // already proved the document exists — but treat defensively). diff --git a/sync-server/src/server/websocket.rs b/sync-server/src/server/websocket.rs index ffac8d38..41bf4754 100644 --- a/sync-server/src/server/websocket.rs +++ b/sync-server/src/server/websocket.rs @@ -115,7 +115,6 @@ async fn websocket( let mut broadcast_receiver = match state .broadcasts .get_receiver(vault_id.clone(), max_clients) - .await { Ok(receiver) => receiver, Err(err) => { From d715d94b6ddd534a963687291766e85db447d648 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 23 Apr 2026 20:35:42 +0100 Subject: [PATCH 07/52] . --- frontend/history-ui/src/lib/api.ts | 7 +- .../src/lib/types/CreateDocumentVersion.ts | 2 +- .../src/lib/types/DocumentUpdateResponse.ts | 4 - .../src/lib/types/DocumentWithCursors.ts | 2 +- .../src/lib/types/WebSocketVaultPathChange.ts | 11 +- frontend/history-ui/src/lib/types/index.ts | 2 + .../file-operations/file-operations.test.ts | 256 +++++---- .../src/file-operations/file-operations.ts | 121 ++--- .../sync-client/src/services/sync-service.ts | 87 ++-- .../services/types/CreateDocumentVersion.ts | 2 +- .../services/types/DeleteDocumentVersion.ts | 5 - .../services/types/DocumentUpdateResponse.ts | 4 - .../src/services/types/DocumentWithCursors.ts | 2 +- .../types/WebSocketVaultPathChange.ts | 11 +- .../src/services/websocket-manager.ts | 6 + frontend/sync-client/src/sync-client.ts | 8 + .../src/sync-operations/cursor-tracker.ts | 26 +- .../src/sync-operations/sync-event-queue.ts | 183 ++++++- .../sync-client/src/sync-operations/syncer.ts | 493 +++++++++++++----- .../src/utils/conflict-path.test.ts | 85 +++ .../sync-client/src/utils/conflict-path.ts | 66 +++ sync-server/src/app_state/database.rs | 58 +-- sync-server/src/app_state/websocket/models.rs | 3 +- sync-server/src/server/delete_document.rs | 3 +- sync-server/src/server/requests.rs | 2 - sync-server/src/utils/sanitize_path.rs | 11 + 26 files changed, 1007 insertions(+), 453 deletions(-) delete mode 100644 frontend/sync-client/src/services/types/DeleteDocumentVersion.ts create mode 100644 frontend/sync-client/src/utils/conflict-path.test.ts create mode 100644 frontend/sync-client/src/utils/conflict-path.ts diff --git a/frontend/history-ui/src/lib/api.ts b/frontend/history-ui/src/lib/api.ts index 6d52a0f7..d80b5eb1 100644 --- a/frontend/history-ui/src/lib/api.ts +++ b/frontend/history-ui/src/lib/api.ts @@ -81,7 +81,12 @@ export class ApiClient { ): Promise { const response = await fetch( `${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}/content`, - { headers: this.headers() } + { + headers: { + Authorization: `Bearer ${this.token}`, + "device-id": "history-ui" + } + } ); if (!response.ok) { throw new Error(`HTTP ${response.status}`); diff --git a/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts b/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts index 86ba60f3..29d3f55e 100644 --- a/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts +++ b/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts @@ -1,3 +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, }; +export type CreateDocumentVersion = { relative_path: string, last_seen_vault_update_id: number, content: Array, }; diff --git a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts b/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts index 48f0fd1c..4e2ef3ab 100644 --- a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts +++ b/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts @@ -4,9 +4,5 @@ import type { DocumentUpdateMetadata } from "./DocumentUpdateMetadata"; /** * Response to a create/update document request. - * - * Neither variant contains `relative_path`: the client tracks the document's - * on-disk path locally and the server is the authority on document identity - * (`document_id`), not on its path. */ export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentUpdateMetadata | { "type": "MergingUpdate" } & DocumentUpdateMergedContent; diff --git a/frontend/history-ui/src/lib/types/DocumentWithCursors.ts b/frontend/history-ui/src/lib/types/DocumentWithCursors.ts index 38857a35..3504ce33 100644 --- a/frontend/history-ui/src/lib/types/DocumentWithCursors.ts +++ b/frontend/history-ui/src/lib/types/DocumentWithCursors.ts @@ -1,4 +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, }; +export type DocumentWithCursors = { vaultUpdateId: number | null, documentId: string, relativePath: string, cursors: Array, }; diff --git a/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts b/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts index 5079b14b..a0af0a7b 100644 --- a/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts +++ b/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts @@ -1,12 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -/** - * A rename notification. Emitted whenever a write commits a document at - * a path that differs from what the origin client sent and/or from the - * document's previous stored path. Unlike [`WebSocketVaultUpdate`] this - * event is delivered to all subscribers *including the origin device*, - * because the create/update HTTP response no longer carries the path and - * the origin needs this event to learn the server-canonical path - * (e.g. when the server deduped or rejected a rename). - */ -export type WebSocketVaultPathChange = { vaultUpdateId: number, documentId: string, relativePath: string, }; +export type WebSocketVaultPathChange = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, userId: string, deviceId: string, }; diff --git a/frontend/history-ui/src/lib/types/index.ts b/frontend/history-ui/src/lib/types/index.ts index a2c2b346..ad1b4d41 100644 --- a/frontend/history-ui/src/lib/types/index.ts +++ b/frontend/history-ui/src/lib/types/index.ts @@ -1,3 +1,5 @@ +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; + export type { DocumentVersion } from "./DocumentVersion"; export type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; export type { FetchLatestDocumentsResponse } from "./FetchLatestDocumentsResponse"; diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index b5851a3e..78977b14 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -1,5 +1,6 @@ import { describe, it } from "node:test"; -import type { DocumentId, DocumentRecord, RelativePath } from "../sync-operations/types"; +import assert from "node:assert/strict"; +import type { RelativePath } from "../sync-operations/types"; import type { SyncEventQueue } from "../sync-operations/sync-event-queue"; import { FileOperations } from "./file-operations"; import { Logger } from "../tracing/logger"; @@ -7,6 +8,7 @@ import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly"; import type { FileSystemOperations } from "./filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; import type { ServerConfig, ServerConfigData } from "../services/server-config"; +import { isConflictPath } from "../utils/conflict-path"; class MockServerConfig implements Pick { public async getConfig(): Promise { @@ -18,18 +20,19 @@ class MockServerConfig implements Pick { } } -class MockQueue implements Pick { - public getDocumentByPath( - _path: RelativePath - ): DocumentRecord | undefined { - return undefined; - } - +// The queue only receives `moveDocument`/`removeDocument` from file-ops; for +// these tests we just need no-op implementations that let the type-check +// pass when cast to `SyncEventQueue`. +class MockQueue implements Pick { public moveDocument( _oldPath: RelativePath, _newPath: RelativePath - ): DocumentId | undefined { - return undefined; + ): void { + // no-op + } + + public removeDocument(_path: RelativePath): void { + // no-op } } @@ -39,7 +42,7 @@ class FakeFileSystemOperations implements FileSystemOperations { public async listFilesRecursively( _root: RelativePath | undefined ): Promise { - return ["file.md"]; + return Array.from(this.names); } public async read(_path: RelativePath): Promise { throw new Error("Method not implemented."); @@ -65,9 +68,6 @@ class FakeFileSystemOperations implements FileSystemOperations { 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."); } @@ -80,152 +80,140 @@ class FakeFileSystemOperations implements FileSystemOperations { } } +function makeOps(): { + fs: FakeFileSystemOperations; + ops: FileOperations; +} { + const fs = new FakeFileSystemOperations(); + const ops = new FileOperations( + new Logger(), + new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + fs, + new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + ); + return { fs, ops }; +} + +function singleConflictPath( + names: Set, + expectedNonConflictNames: string[] +): string { + const expected = new Set(expectedNonConflictNames); + const conflicts = Array.from(names).filter( + (name) => !expected.has(name) + ); + assert.equal( + conflicts.length, + 1, + `expected exactly one conflict-path entry, got ${JSON.stringify(conflicts)}` + ); + assert.ok( + isConflictPath(conflicts[0]), + `expected ${conflicts[0]} to match the conflict-path pattern` + ); + return conflicts[0]; +} + describe("File operations", () => { - it("should deconflict renames", async () => { - const fileSystemOperations = new FakeFileSystemOperations(); - const fileOperations = new FileOperations( - new Logger(), - new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - fileSystemOperations, - new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - ); + it("move to empty target just renames the file", async () => { + const { fs, ops } = makeOps(); - await fileOperations.create("a", new Uint8Array()); - assertSetContainsExactly(fileSystemOperations.names, "a"); - await fileOperations.move("a", "b"); - assertSetContainsExactly(fileSystemOperations.names, "b"); + await ops.create("a", new Uint8Array()); + assertSetContainsExactly(fs.names, "a"); - await fileOperations.create("c", new Uint8Array()); - assertSetContainsExactly(fileSystemOperations.names, "b", "c"); + await ops.move("a", "b"); + assertSetContainsExactly(fs.names, "b"); + }); - await fileOperations.move("c", "b"); - assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)"); + it("create at an occupied path displaces the existing file to a conflict-uuid path", async () => { + const { fs, ops } = makeOps(); - await fileOperations.create("c", new Uint8Array()); - await fileOperations.move("c", "b"); - assertSetContainsExactly( - fileSystemOperations.names, - "b", - "b (1)", - "b (2)" + await ops.create("note.md", new Uint8Array()); + await ops.create("note.md", new Uint8Array()); + + const conflict = singleConflictPath(fs.names, ["note.md"]); + assert.ok( + conflict.endsWith("-note.md"), + `conflict name should preserve the original filename, got ${conflict}` ); }); - it("should deconflict renames with file extension", async () => { - const fileSystemOperations = new FakeFileSystemOperations(); - const fileOperations = new FileOperations( - new Logger(), - new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - fileSystemOperations, - new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - ); + it("move to an occupied target displaces the target to a conflict-uuid path", async () => { + const { fs, ops } = makeOps(); - 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 ops.create("source.md", new Uint8Array()); + await ops.create("dest.md", new Uint8Array()); - 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 ops.move("source.md", "dest.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" + // `dest.md` now holds what used to be at `source.md`; the original + // `dest.md` moved to a conflict path in the same directory. + const conflict = singleConflictPath(fs.names, ["dest.md"]); + assert.ok( + conflict.endsWith("-dest.md"), + `conflict should preserve the original filename, got ${conflict}` ); }); - it("should deconflict renames with paths", async () => { - const fileSystemOperations = new FakeFileSystemOperations(); - const fileOperations = new FileOperations( - new Logger(), - new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - fileSystemOperations, - new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - ); + it("preserves the parent directory when generating a conflict path", async () => { + const { fs, ops } = makeOps(); - await fileOperations.create("a/b.c/d", new Uint8Array()); - await fileOperations.create("a/b.c/e", new Uint8Array()); - await fileOperations.move("a/b.c/d", "a/b.c/e"); - assertSetContainsExactly( - fileSystemOperations.names, - "a/b.c/e", - "a/b.c/e (1)" + await ops.create("a/b.c/d", new Uint8Array()); + await ops.create("a/b.c/e", new Uint8Array()); + await ops.move("a/b.c/d", "a/b.c/e"); + + const conflict = singleConflictPath(fs.names, ["a/b.c/e"]); + assert.ok( + conflict.startsWith("a/b.c/"), + `conflict should live in the same directory, got ${conflict}` + ); + assert.ok( + conflict.endsWith("-e"), + `conflict should preserve the filename, got ${conflict}` ); }); - it("should continue deconfliction from existing number in filename", async () => { - const fileSystemOperations = new FakeFileSystemOperations(); - const fileOperations = new FileOperations( - new Logger(), - new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - fileSystemOperations, - new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + it("handles dotfiles without mangling the extension", async () => { + const { fs, ops } = makeOps(); + + await ops.create(".gitignore", new Uint8Array()); + await ops.create("temp", new Uint8Array()); + await ops.move("temp", ".gitignore"); + + const conflict = singleConflictPath(fs.names, [".gitignore"]); + assert.ok( + conflict.endsWith("-.gitignore"), + `conflict should preserve the dotfile name verbatim, got ${conflict}` ); - await fileOperations.create("document (5).md", new Uint8Array()); - await fileOperations.create("other.md", new Uint8Array()); + await ops.create(".config.json", new Uint8Array()); + await ops.create("temp2", new Uint8Array()); + await ops.move("temp2", ".config.json"); - 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" + // Now one conflict for .gitignore, one for .config.json. + const conflicts = Array.from(fs.names).filter( + (name) => name !== ".gitignore" && name !== ".config.json" ); + assert.equal(conflicts.length, 2); + assert.ok(conflicts.every(isConflictPath)); + assert.ok(conflicts.some((c) => c.endsWith("-.gitignore"))); + assert.ok(conflicts.some((c) => c.endsWith("-.config.json"))); }); - it("should handle dotfiles correctly", async () => { - const fileSystemOperations = new FakeFileSystemOperations(); - const fileOperations = new FileOperations( - new Logger(), - new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - fileSystemOperations, - new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - ); + it("generates a fresh conflict path on every displacement", async () => { + const { fs, ops } = makeOps(); - await fileOperations.create(".gitignore", new Uint8Array()); - await fileOperations.create("temp", new Uint8Array()); - await fileOperations.move("temp", ".gitignore"); - assertSetContainsExactly( - fileSystemOperations.names, - ".gitignore", - ".gitignore (1)" - ); + await ops.create("x", new Uint8Array()); + await ops.create("x", new Uint8Array()); + await ops.create("x", new Uint8Array()); - 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" + const conflicts = Array.from(fs.names).filter((n) => n !== "x"); + assert.equal(conflicts.length, 2); + assert.ok(conflicts.every(isConflictPath)); + assert.notEqual( + conflicts[0], + conflicts[1], + "each displacement should produce a unique conflict path" ); }); }); diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 8a9b10ba..3b3d50c4 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -7,10 +7,10 @@ import type { TextWithCursors } from "reconcile-text"; import { reconcile } from "reconcile-text"; import { isFileTypeMergable } from "../utils/is-file-type-mergable"; import { isBinary } from "../utils/is-binary"; +import { buildConflictFileName } from "../utils/conflict-path"; import type { ServerConfig } from "../services/server-config"; export class FileOperations { - private static readonly PARENTHESES_REGEX = / \((?\d+)\)$/; private readonly fs: SafeFileSystemOperations; public constructor( @@ -59,26 +59,34 @@ export class FileOperations { return this.fs.write(path, this.toNativeLineEndings(newContent)); } - // Returns the deconflicted path if a file was moved, undefined otherwise + /** + * Ensure nothing sits at `path` so the caller can write to it. + * + * If a file is already there, it is moved aside to a `conflict--` + * path in the same directory. The sync layer treats conflict-named files + * as invisible (see `isConflictPath`), so no events are enqueued and no + * document records are touched — any pre-existing record or pending + * events for the displaced path are left behind for the caller to + * overwrite as part of whatever operation prompted the displacement. + * + * Returns the conflict path the existing file was moved to, or `undefined` + * if the path was already clear. + */ public async ensureClearPath( path: RelativePath ): Promise { if (await this.fs.exists(path)) { - const deconflictedPath = await this.deconflictPath(path); - try { - this.logger.debug( - `Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'` - ); + const conflictPath = FileOperations.buildConflictPath(path); + this.logger.debug( + `Displacing existing file at ${path} to '${conflictPath}' to make room` + ); - this.queue.moveDocument(path, deconflictedPath); - await this.fs.rename(path, deconflictedPath, true); - return deconflictedPath; - } finally { - this.fs.unlock(deconflictedPath); - } - } else { - await this.createParentDirectories(path); + this.queue.moveDocument(path, conflictPath); + await this.fs.rename(path, conflictPath, true); + return conflictPath; } + + await this.createParentDirectories(path); return undefined; } @@ -119,8 +127,22 @@ export class FileOperations { return; } - const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings - const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings + let expectedText: string; + let newText: string; + try { + expectedText = new TextDecoder("utf-8", { fatal: true }).decode( + expectedContent + ); // this comes from a previous read which must only have \n line endings + newText = new TextDecoder("utf-8", { fatal: true }).decode( + newContent + ); // this comes from the server which stores text with \n line endings + } catch (decodeError) { + this.logger.warn( + `3-way merge aborted for ${path}: one of expected/new is not valid UTF-8 (${decodeError}); falling back to overwrite` + ); + await this.fs.write(path, this.toNativeLineEndings(newContent)); + return; + } await this.fs.atomicUpdateText( path, @@ -166,7 +188,7 @@ export class FileOperations { return this.fs.exists(path); } - // Returns the deconflicted path if a file at the target was displaced + // Returns the conflict path a displaced file was moved to, or undefined. public async move( oldPath: RelativePath, newPath: RelativePath @@ -175,12 +197,16 @@ export class FileOperations { return undefined; } - const deconflictedPath = await this.ensureClearPath(newPath); - this.queue.moveDocument(oldPath, newPath); + const conflictPath = await this.ensureClearPath(newPath); + // Do the disk rename *before* updating the queue. If the rename + // throws (permissions, concurrent deletion, …), the queue still + // reflects the actual on-disk state instead of claiming the doc + // has already moved. await this.fs.rename(oldPath, newPath); + this.queue.moveDocument(oldPath, newPath); await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); - return deconflictedPath; + return conflictPath; } @@ -248,51 +274,14 @@ export class FileOperations { } /** - * Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found. - * The returned path has a lock acquired on it; it must be released by the caller when no longer needed. - * - * @param path The starting path to deconflict - * @returns a non-existent path with a lock acquired on it + * Build a local-only conflict path for a file the client has to set aside. + * Format: `/conflict--` — UUID makes collisions + * statistically impossible, so no disk probe / lock dance is needed. */ - private async deconflictPath(path: RelativePath): Promise { - // eslint-disable-next-line prefer-const - let [directory, fileName] = FileOperations.getParentDirAndFile(path); - - if (directory) { - directory += "/"; - } - - const nameParts = fileName.split("."); - // Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json" - const isDotfile = fileName.startsWith(".") && nameParts[0] === ""; - const extension = - nameParts.length > 1 && !(isDotfile && nameParts.length === 2) - ? "." + nameParts[nameParts.length - 1] - : ""; - let stem = extension ? nameParts.slice(0, -1).join(".") : fileName; - let currentCount = Number.parseInt( - FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0" - ); - stem = stem.replace(FileOperations.PARENTHESES_REGEX, ""); - - let newName = path; - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - while (true) { - currentCount++; - newName = `${directory}${stem} (${currentCount})${extension}`; - - // Avoid multiple deconflictPath calls returning the same path - await this.fs.waitForLock(newName); - const existingRecord = this.queue.getSettledDocumentByPath(newName); - if ( - existingRecord !== undefined || // the document might have been confirmed by the server at a new path but haven't yet moved there locally - (await this.fs.exists(newName, true)) - ) { - this.fs.unlock(newName); - } else { - return newName; - } - } + private static buildConflictPath(path: RelativePath): RelativePath { + const [directory, fileName] = + FileOperations.getParentDirAndFile(path); + const conflictName = buildConflictFileName(fileName); + return directory ? `${directory}/${conflictName}` : conflictName; } } diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index ad268814..873783c3 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -16,12 +16,12 @@ import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; import type { DocumentVersion } from "./types/DocumentVersion"; import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse"; import type { PingResponse } from "./types/PingResponse"; -import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion"; import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion"; export class SyncService { private readonly client: typeof globalThis.fetch; private readonly pingClient: typeof globalThis.fetch; + private isStopped = false; public constructor( private readonly deviceId: string, @@ -68,15 +68,21 @@ export class SyncService { public async create({ relativePath, + lastSeenVaultUpdateId, contentBytes }: { relativePath: RelativePath; + lastSeenVaultUpdateId: VaultUpdateId; contentBytes: Uint8Array; }): Promise { return this.retryForever(async () => { const formData = new FormData(); formData.append("relative_path", relativePath); + formData.append( + "last_seen_vault_update_id", + lastSeenVaultUpdateId.toString() + ); formData.append( "content", new Blob([new Uint8Array(contentBytes)]) @@ -92,13 +98,7 @@ export class SyncService { headers: this.getDefaultHeaders() }); - if (!response.ok) { - throw new Error( - `Failed to create document: ${await SyncService.errorFromResponse( - response - )}` - ); - } + await SyncService.throwIfNotOk(response, "create document"); const result: DocumentUpdateResponse = (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion @@ -210,30 +210,21 @@ export class SyncService { relativePath: RelativePath; }): Promise { return this.retryForever(async () => { - const request: DeleteDocumentVersion = { - relativePath - }; - this.logger.debug( `Delete document with id ${documentId} and relative path ${relativePath}` ); + // The server identifies the document by its URL path; no body + // is needed. Sending one was a leftover of an earlier shape. const response = await this.client( this.getUrl(`/documents/${documentId}`), { method: "DELETE", - body: JSON.stringify(request), - headers: this.getDefaultHeaders({ type: "json" }) + headers: this.getDefaultHeaders() } ); - if (!response.ok) { - throw new Error( - `Failed to delete document: ${await SyncService.errorFromResponse( - response - )}` - ); - } + await SyncService.throwIfNotOk(response, "delete document"); const result: DocumentVersionWithoutContent = (await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion @@ -261,13 +252,7 @@ export class SyncService { } ); - if (!response.ok) { - throw new Error( - `Failed to get document: ${await SyncService.errorFromResponse( - response - )}` - ); - } + await SyncService.throwIfNotOk(response, "get document"); const result: DocumentVersion = (await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion @@ -299,13 +284,7 @@ export class SyncService { } ); - if (!response.ok) { - throw new Error( - `Failed to get document: ${await SyncService.errorFromResponse( - response - )}` - ); - } + await SyncService.throwIfNotOk(response, "get document version content"); const result = await response.bytes(); this.logger.debug( @@ -332,13 +311,7 @@ export class SyncService { headers: this.getDefaultHeaders() }); - if (!response.ok) { - throw new Error( - `Failed to get documents: ${await SyncService.errorFromResponse( - response - )}` - ); - } + await SyncService.throwIfNotOk(response, "get documents"); const result: FetchLatestDocumentsResponse = (await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion @@ -396,9 +369,30 @@ export class SyncService { return headers; } + /** + * Signal that the service is shutting down so any in-flight + * `retryForever` exits at its next iteration instead of looping + * indefinitely after the rest of the client has stopped. Idempotent. + */ + public stop(): void { + this.isStopped = true; + } + + /** + * Re-enable the service after a `stop()`. Used when the client pauses + * and resumes syncing within the same lifecycle (e.g. user toggles + * sync off and on). + */ + public resume(): void { + this.isStopped = false; + } + private async retryForever(fn: () => Promise): Promise { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { + if (this.isStopped) { + throw new SyncResetError(); + } try { return await fn(); } catch (e) { @@ -408,6 +402,9 @@ export class SyncService { ) { throw e; } + if (this.isStopped) { + throw new SyncResetError(); + } const retryInterval = this.settings.getSettings().networkRetryIntervalMs; @@ -425,6 +422,12 @@ export class SyncService { ): Promise { if (response.ok) return; const message = `Failed to ${operation}: ${await SyncService.errorFromResponse(response)}`; + // 429 is the only 4xx the server uses for *transient* contention + // (`WriteBusyError` → HTTP 429). Every other 4xx means the request + // is permanently rejected and shouldn't be retried. + if (response.status === 429) { + throw new Error(message); + } if (response.status >= 400 && response.status < 500) { throw new HttpClientError(response.status, message); } diff --git a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts index d4ed2831..4d1b324e 100644 --- a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface CreateDocumentVersion { relative_path: string, content: number[], } +export interface CreateDocumentVersion { relative_path: string, last_seen_vault_update_id: number, content: number[], } diff --git a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts deleted file mode 100644 index 99ecc9e7..00000000 --- a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts +++ /dev/null @@ -1,5 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export interface DeleteDocumentVersion { - relativePath: string; -} diff --git a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts index 48f0fd1c..4e2ef3ab 100644 --- a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts +++ b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts @@ -4,9 +4,5 @@ import type { DocumentUpdateMetadata } from "./DocumentUpdateMetadata"; /** * Response to a create/update document request. - * - * Neither variant contains `relative_path`: the client tracks the document's - * on-disk path locally and the server is the authority on document identity - * (`document_id`), not on its path. */ export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentUpdateMetadata | { "type": "MergingUpdate" } & DocumentUpdateMergedContent; diff --git a/frontend/sync-client/src/services/types/DocumentWithCursors.ts b/frontend/sync-client/src/services/types/DocumentWithCursors.ts index e7dad119..d29b3f79 100644 --- a/frontend/sync-client/src/services/types/DocumentWithCursors.ts +++ b/frontend/sync-client/src/services/types/DocumentWithCursors.ts @@ -1,4 +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 interface DocumentWithCursors { vault_update_id: number | null, document_id: string, relative_path: string, cursors: CursorSpan[], } +export interface DocumentWithCursors { vaultUpdateId: number | null, documentId: string, relativePath: string, cursors: CursorSpan[], } diff --git a/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts b/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts index 337eb135..f4b5bb84 100644 --- a/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts +++ b/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts @@ -1,12 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -/** - * A rename notification. Emitted whenever a write commits a document at - * a path that differs from what the origin client sent and/or from the - * document's previous stored path. Unlike [`WebSocketVaultUpdate`] this - * event is delivered to all subscribers *including the origin device*, - * because the create/update HTTP response no longer carries the path and - * the origin needs this event to learn the server-canonical path - * (e.g. when the server deduped or rejected a rename). - */ -export interface WebSocketVaultPathChange { vaultUpdateId: number, documentId: string, relativePath: string, } +export interface WebSocketVaultPathChange { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, userId: string, deviceId: string, } diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index 5ec40e49..6c938dc7 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -181,6 +181,12 @@ export class WebSocketManager { `Failed to close previous WebSocket connection: ${e}` ); } + // Abandon any outstanding handler promises from the previous + // connection. They'll still resolve in the background, but we + // no longer want `waitUntilFinished` / `stop` to block on + // post-reconnect state — and we definitely don't want their + // results applied against a now-stale socket. + this.outstandingPromises.length = 0; } const wsUri = new URL(this.settings.getSettings().remoteUri); diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 1a88c269..ff1c3841 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -48,6 +48,7 @@ export class SyncClient { private readonly contentCache: FixedSizeDocumentCache, private readonly fileOperations: FileOperations, private readonly serverConfig: ServerConfig, + private readonly syncService: SyncService, private readonly persistence: PersistenceProvider< Partial<{ settings: Partial; @@ -221,6 +222,7 @@ export class SyncClient { contentCache, fileOperations, serverConfig, + syncService, persistence ); @@ -460,6 +462,8 @@ export class SyncClient { private async startSyncing(): Promise { this.checkIfDestroyed("startSyncing"); this.fetchController.finishReset(); + // Undo any earlier `pause()` stop so retryForever keeps retrying. + this.syncService.resume(); await this.serverConfig.getConfig(); @@ -472,6 +476,10 @@ export class SyncClient { private async pause(): Promise { this.hasFinishedOfflineSync = false; this.fetchController.startReset(); + // Signal the service so any `retryForever` loop exits at its next + // iteration instead of continuing to retry a network request while + // the rest of the client is winding down. + this.syncService.stop(); await this.webSocketManager.stop(); await this.waitUntilFinished(); } diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.ts index 703f654c..98548f73 100644 --- a/frontend/sync-client/src/sync-operations/cursor-tracker.ts +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.ts @@ -58,7 +58,7 @@ export class CursorTracker { for (const cursor of clientCursors.filter((client) => client.documentsWithCursors.every( - (doc) => doc.vault_update_id != null + (doc) => doc.vaultUpdateId != null ) )) { updatedKnownRemoteCursors.push({ @@ -83,7 +83,7 @@ export class CursorTracker { if ( clientCursor.documentsWithCursors.some( (document) => - document.relative_path === relativePath + document.relativePath === relativePath ) ) { clientCursor.upToDateness = @@ -112,9 +112,9 @@ export class CursorTracker { } documentsWithCursors.push({ - relative_path: relativePath, - document_id: record.documentId, - vault_update_id: record.parentVersionId, + relativePath: relativePath, + documentId: record.documentId, + vaultUpdateId: record.parentVersionId, cursors: cursors.map(({ start, end }) => ({ start: Math.min(start, end), end: Math.max(start, end) @@ -133,11 +133,11 @@ export class CursorTracker { for (const doc of documentsWithCursors) { const readContent = await this.fileOperations.read( - doc.relative_path + doc.relativePath ); - const record = this.queue.getSettledDocumentByPath(doc.relative_path); + const record = this.queue.getSettledDocumentByPath(doc.relativePath); if (record?.remoteHash !== (await hash(readContent))) { - doc.vault_update_id = null; + doc.vaultUpdateId = null; } } @@ -221,7 +221,7 @@ export class CursorTracker { private async getDocumentUpToDateness( document: DocumentWithCursors ): Promise { - const record = this.queue.getSettledDocumentByPath(document.relative_path); + const record = this.queue.getSettledDocumentByPath(document.relativePath); if (!record) { // the document of the cursor must be from the future @@ -229,21 +229,21 @@ export class CursorTracker { } if ( - record.parentVersionId < (document.vault_update_id ?? 0) + record.parentVersionId < (document.vaultUpdateId ?? 0) ) { return DocumentUpToDateness.Later; } else if ( - (document.vault_update_id ?? 0) < record.parentVersionId + (document.vaultUpdateId ?? 0) < record.parentVersionId ) { // the document of the cursor must be from the past return DocumentUpToDateness.Prior; } const currentContent = await this.fileOperations.read( - document.relative_path + document.relativePath ); - const currentRecord = this.queue.getSettledDocumentByPath(document.relative_path); + const currentRecord = this.queue.getSettledDocumentByPath(document.relativePath); return currentRecord?.remoteHash === (await hash(currentContent)) ? DocumentUpToDateness.UpToDate : DocumentUpToDateness.Prior; diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index 9e10ba94..a49ce71f 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -1,6 +1,7 @@ import type { Settings } from "../persistence/settings"; import type { Logger } from "../tracing/logger"; import { globsToRegexes } from "../utils/globs-to-regexes"; +import { isConflictPath } from "../utils/conflict-path"; import { removeFromArray } from "../utils/remove-from-array"; import { SyncEventType, @@ -110,6 +111,59 @@ export class SyncEventQueue { this.saveInTheBackground(); } + /** + * Reflect a local rename in the queue's disk-path index. + * + * Mirrors the `input.oldPath !== undefined` branch of `enqueue`, but + * without emitting a new `SyncLocal` — used by `FileOperations.move` + * when the rename is a byproduct of another sync operation (e.g. the + * user dragging a file) and the caller will push the resulting event + * separately, or not at all. + * + * If the rename targets a path that already holds a settled record + * (e.g. concurrent clobber), the destination's record is dropped: the + * caller is expected to have moved the displaced file out of the way + * via `ensureClearPath` already, so the dropped record reflects the + * now-orphaned disk state. + */ + public moveDocument( + oldPath: RelativePath, + newPath: RelativePath + ): void { + if (oldPath === newPath) return; + + const record = this.documents.get(oldPath); + if (record !== undefined) { + // If `newPath` already holds a settled record, overwriting it + // silently would orphan that document's identity. Warn so the + // bug is visible; the caller is expected to have freed the + // destination via `ensureClearPath` first. + const clobbered = this.documents.get(newPath); + if (clobbered !== undefined) { + this.logger.warn( + `moveDocument(${oldPath} → ${newPath}) is overwriting a settled record for document ${clobbered.documentId}; caller should have displaced it first` + ); + } + + this.documents.delete(oldPath); + this.documents.set(newPath, record); + for (const e of this.events) { + if ( + e.type === SyncEventType.SyncLocal && + e.documentId === record.documentId + ) { + e.path = newPath; + } + } + this.saveInTheBackground(); + return; + } + + // No settled record — the rename may be over a pending Create + // whose document hasn't been persisted on the server yet. + this.updatePendingCreatePath(oldPath, newPath); + } + /** * Call once a create has been acknowledged by the server. */ @@ -232,11 +286,24 @@ export class SyncEventQueue { const { path } = input; + // Conflict-displaced files are local-only bookkeeping so a conflict + // hit is a debug-level event. A hit against a user-configured glob + // is a higher-signal "we're deliberately not syncing this" and + // stays at info. + if (isConflictPath(path)) { + this.logger.debug( + `Ignoring ${input.type} for ${path}: conflict-displaced file` + ); + return; + } + if (this.matchesUserIgnorePattern(path)) { + this.logger.info( + `Ignoring ${input.type} for ${path} as it matches ignore patterns` + ); + return; + } + if (input.type === SyncEventType.Create) { - if (this.isIgnored(path)) { - this.logger.info(`Ignoring create for ${path} as it matches ignore patterns`); - return; - } this.events.push({ type: SyncEventType.Create, path, originalPath: path }); return; } @@ -284,11 +351,23 @@ export class SyncEventQueue { // Deletes are returned immediately; also discard any subsequent // events for the same documentId so stale broadcasts don't - // resurrect the document + // resurrect the document. If the documentId is still a pending + // `Promise` (the originating Create hasn't landed + // yet), awaiting it may reject — handle that: the Create was + // cancelled, so the Delete has nothing to delete, just drop it. if (first.type === SyncEventType.Delete) { this.events.shift(); const { documentId } = first; - this.removeAllEventsForDocumentId(await documentId); + let resolvedId: DocumentId; + try { + resolvedId = await documentId; + } catch { + this.logger.debug( + "Dropping Delete whose Create was cancelled before it could be synced" + ); + return this.next(); + } + this.removeAllEventsForDocumentId(resolvedId); return first; } @@ -303,7 +382,16 @@ export class SyncEventQueue { e.documentId === documentId ); if (deleteEvent !== undefined) { - this.removeAllEventsForDocumentId(await documentId); + let resolvedId: DocumentId; + try { + resolvedId = await documentId; + } catch { + this.logger.debug( + "Dropping SyncLocal+Delete whose Create was cancelled before it could be synced" + ); + return this.next(); + } + this.removeAllEventsForDocumentId(resolvedId); return deleteEvent; } @@ -336,10 +424,14 @@ export class SyncEventQueue { return result; } - private isIgnored(path: RelativePath): boolean { + private matchesUserIgnorePattern(path: RelativePath): boolean { return this.ignorePatterns.some((pattern) => pattern.test(path)); } + private isIgnored(path: RelativePath): boolean { + return isConflictPath(path) || this.matchesUserIgnorePattern(path); + } + public removeAllEventsForDocumentId(documentId: DocumentId): void { for (let i = this.events.length - 1; i >= 0; i--) { const e = this.events[i]; @@ -406,6 +498,41 @@ export class SyncEventQueue { return undefined; } + /** + * Returns whether there is an unsynced Create event queued at `path`. + * A caller uses this to decide between displacing the local file vs. + * merging it with a concurrent remote create. + */ + public hasPendingCreateAt(path: RelativePath): boolean { + return this.findLastCreate(path) !== undefined; + } + + /** + * Cancel the latest queued Create for `path`. Rejects its resolver + * promise (so any dependent SyncLocal/Delete events that `await`ed + * the future documentId skip themselves gracefully) and removes the + * Create event from the queue. Returns true if a Create was found + * and cancelled. + */ + public cancelPendingCreate(path: RelativePath): boolean { + const event = this.findLastCreate(path); + if (event === undefined) return false; + + if (event.resolvers !== undefined) { + event.resolvers.promise.catch(() => { + /* suppressed — consumer may not be listening */ + }); + event.resolvers.reject( + new Error( + "Create was cancelled — merged with concurrent remote create" + ) + ); + } + + removeFromArray(this.events, event); + return true; + } + private rejectAllPendingCreates(): void { for (const event of this.events) { if (event.type === SyncEventType.Create && event.resolvers !== undefined) { @@ -415,9 +542,45 @@ export class SyncEventQueue { } } + private savePending = false; + + // Coalesce bursts of mutations into one persist per microtask. A drain + // iteration can easily produce 10+ mutations; without this, we'd fire + // 10 overlapping `save()` calls racing on the persistence backend. + // + // On failure, retry with bounded exponential backoff instead of + // silently dropping the write — otherwise a transient IDB/fs error + // leaves the in-memory state permanently diverged from persisted state + // and the user loses queue progress on restart. private saveInTheBackground(): void { - void this.save().catch((error: unknown) => { - this.logger.error(`Error saving sync state: ${error}`); + if (this.savePending) return; + this.savePending = true; + queueMicrotask(() => { + this.savePending = false; + void this.saveWithRetry(); }); } + + private async saveWithRetry(): Promise { + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + await this.save(); + return; + } catch (error) { + if (attempt === maxAttempts) { + this.logger.error( + `Error saving sync state after ${maxAttempts} attempts: ${error}` + ); + return; + } + this.logger.warn( + `Error saving sync state (attempt ${attempt}/${maxAttempts}): ${error}; retrying` + ); + await new Promise((resolve) => + setTimeout(resolve, 50 * attempt) + ); + } + } + } } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 7e772432..4e51976d 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -32,7 +32,7 @@ import { } from "../tracing/sync-history"; import { isBinary } from "../utils/is-binary"; import { isFileTypeMergable } from "../utils/is-file-type-mergable"; -import { diff } from "reconcile-text"; +import { diff, reconcile } from "reconcile-text"; import type { ServerConfig } from "../services/server-config"; import type { FixedSizeDocumentCache } from "../utils/data-structures/fix-sized-cache"; import { base64ToBytes } from "byte-base64"; @@ -68,7 +68,26 @@ export class Syncer { if (isConnected) { this.sendHandshakeMessage(); } else { - this.runningScheduleSyncForOfflineChanges = undefined; + // Don't null the reference synchronously — if the scan is + // still in flight, the next reconnect would spawn a second + // concurrent scan racing on the same queue. Defer the + // clear until the in-flight task actually resolves, so a + // fresh scan can only start once the prior one is done. + const current = this.runningScheduleSyncForOfflineChanges; + if (current === undefined) return; + current + .catch(() => { + /* swallow — internal error already logged */ + }) + .finally(() => { + if ( + this.runningScheduleSyncForOfflineChanges === + current + ) { + this.runningScheduleSyncForOfflineChanges = + undefined; + } + }); } }); this.webSocketManager.onRemoteVaultUpdateReceived.add( @@ -182,46 +201,64 @@ export class Syncer { // because the create/update HTTP response no longer carries the path, // so the only way the origin learns about dedupe or first-rename-wins // is via this event. + // + // Algorithmic assumptions: + // (1) Per-vault broadcast ordering is preserved by the server, so if + // the same write produced a `VaultUpdate` (content change) and a + // `PathChange` (path change), the `VaultUpdate` is handled first + // — that's what lets us skip advancing `parentVersionId` here + // without risking a stuck "already up-to-date" check later. + // (2) On a lag-induced disconnect (`broadcast::error::Lagged`) the + // server disconnects the client for a full resync, so out-of- + // order delivery across a reconnect boundary can't leave us with + // a stale PathChange overwriting a newer one. public async syncRemotelyChangedPath( pathChange: WebSocketVaultPathChange ): Promise { + // Serialize onto the drain chain so this handler can't race against + // an in-flight `processSyncRemote` / `processSyncLocal` etc. that + // captured the old path before our move. try { - const existing = this.queue.getDocumentByDocumentId( - pathChange.documentId - ); - if (existing === undefined) { - throw new Error( - `Received path change for unknown document ${pathChange.documentId}` + await this.chainOntoDrain(async () => { + const existing = this.queue.getDocumentByDocumentId( + pathChange.documentId ); - } + if (existing === undefined) { + throw new Error( + `Received path change for unknown document ${pathChange.documentId}` + ); + } - const { path: currentPath, record } = existing; - const newPath = pathChange.relativePath; + const { path: currentPath, record } = existing; + const newPath = pathChange.relativePath; - if (currentPath !== newPath) { - await this.operations.move(currentPath, newPath); + if (currentPath !== newPath) { + await this.operations.move(currentPath, newPath); - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.MOVE, - relativePath: newPath, - movedFrom: currentPath - }, - message: "Applied remote path change" + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: { + type: SyncType.MOVE, + relativePath: newPath, + movedFrom: currentPath + }, + message: "Applied remote path change", + author: pathChange.userId, + timestamp: new Date(pathChange.updatedDate) + }); + } + + // `operations.move` updates the queue's path index, but + // doesn't touch `remoteRelativePath`. Refresh it so offline + // change detection compares against the server's path. + // parentVersionId intentionally stays at its prior value: + // if the write also changed content, the corresponding + // VaultUpdate handles that; advancing it here would make us + // skip fetching content we don't yet have. + this.queue.setDocument(newPath, { + ...record, + remoteRelativePath: newPath }); - } - - // `operations.move` updates the queue's path index, but - // doesn't touch `remoteRelativePath`. Refresh it so offline - // change detection compares against the server's path. - // parentVersionId intentionally stays at its prior value: - // if the write also changed content, the corresponding - // VaultUpdate handles that; advancing it here would make us - // skip fetching content we don't yet have. - this.queue.setDocument(newPath, { - ...record, - remoteRelativePath: newPath }); } catch (e) { if (e instanceof SyncResetError) { @@ -258,12 +295,19 @@ export class Syncer { private async internalScheduleSyncForOfflineChanges(): Promise { - await scheduleOfflineChanges( - { logger: this.logger, operations: this.operations, queue: this.queue }, - (path) => { this.syncLocallyCreatedFile(path); }, - (args) => { this.syncLocallyUpdatedFile(args); }, - (path) => { this.syncLocallyDeletedFile(path); }, - ); + // Offline scan wipes the event queue via `queue.clear()` and then + // rebuilds events from disk. That MUST NOT race against an + // in-flight drain iteration that may already hold a reference to + // a freshly-cleared event — chain onto the drain so the scan runs + // between drain ticks, never concurrently. + await this.chainOntoDrain(async () => { + await scheduleOfflineChanges( + { logger: this.logger, operations: this.operations, queue: this.queue }, + (path) => { this.syncLocallyCreatedFile(path); }, + (args) => { this.syncLocallyUpdatedFile(args); }, + (path) => { this.syncLocallyDeletedFile(path); }, + ); + }); await this.scheduleDrain(); } @@ -271,9 +315,27 @@ export class Syncer { private ensureDraining(): void { - this.draining = (this.draining ?? Promise.resolve()).then( - async () => this.drain() + void this.chainOntoDrain(async () => this.drain()); + } + + /** + * Serialize a unit of work onto the same promise chain the drain + * uses. This is how direct WebSocket handlers (`syncRemotelyChangedPath`, + * offline-scan) avoid racing against the drain loop: every mutator of + * the queue / disk goes through this single chain, in order of arrival. + */ + private async chainOntoDrain(work: () => Promise): Promise { + const chained = (this.draining ?? Promise.resolve()).then( + async () => work() ); + // We track the chain via `this.draining` so later work chains onto + // the latest link. Swallow the result-typed value for storage; the + // caller still awaits the true result via `chained`. + this.draining = chained.then( + () => undefined, + () => undefined + ); + return chained; } private async scheduleDrain(): Promise { @@ -338,6 +400,20 @@ export class Syncer { this.logger.error( `Server rejected ${event.type} request: ${e.message}` ); + // The event was already shifted off the queue before + // `processEvent` ran; if it was a Create, its resolver + // promise would otherwise hang forever, blocking any + // queued Delete / SyncLocal that `await`s it. + if (event.type === SyncEventType.Create) { + event.resolvers?.promise.catch(() => { + /* suppressed */ + }); + event.resolvers?.reject( + new Error( + `Create was cancelled — server rejected the request (${e.message})` + ) + ); + } return; } throw e; @@ -366,6 +442,7 @@ export class Syncer { const response = await this.syncService.create({ relativePath: event.originalPath, + lastSeenVaultUpdateId: this.queue.lastSeenUpdateId, contentBytes }); @@ -394,7 +471,8 @@ export class Syncer { path: effectivePath, response, contentHash, - originalContentBytes: contentBytes + originalContentBytes: contentBytes, + createEvent: event }); this.history.addHistoryEntry({ @@ -658,61 +736,71 @@ export class Syncer { } else { const responseBytes = base64ToBytes(fullVersion.contentBase64); - // Handle remote path change - let actualPath = currentPath; + // Path reconciliation fallback for the reconnect case. + // + // In steady-state streaming, server-initiated renames arrive as + // dedicated `PathChange` WebSocket events and are handled by + // `syncRemotelyChangedPath`. But the reconnect catch-up path + // (`get_unseen_documents` → `VaultUpdate(is_initial_sync=…)`) + // replays *versions* from the DB — `PathChange` is emission- + // only and not replayed. Without this branch, a pure rename + // that happened while we were disconnected would leave our + // local file stuck at its old path forever. + // + // Only apply the server's path when the record's + // `remoteRelativePath` still matches `currentPath` — that means + // we haven't locally renamed since we last heard from the + // server, so the server's path is authoritative. Any local + // rename in flight keeps priority (it'll be resolved by the + // server on its next write). + let targetPath = currentPath; if ( fullVersion.relativePath !== currentPath && record.remoteRelativePath === currentPath ) { - actualPath = fullVersion.relativePath; - await this.operations.delete(fullVersion.relativePath); - await this.operations.move( - currentPath, - fullVersion.relativePath - ); + await this.operations.move(currentPath, fullVersion.relativePath); + targetPath = fullVersion.relativePath; } await this.operations.write( - actualPath, + targetPath, contentBytes, responseBytes ); // Re-read and re-hash after write (the 3-way merge may produce different content) - const afterWriteBytes = await this.operations.read(actualPath); + const afterWriteBytes = await this.operations.read(targetPath); const afterWriteHash = await hash(afterWriteBytes); - this.queue.setDocument(actualPath, { + if (targetPath !== currentPath) { + this.queue.removeDocument(currentPath); + } + this.queue.setDocument(targetPath, { documentId: fullVersion.documentId, parentVersionId: fullVersion.vaultUpdateId, remoteHash: afterWriteHash, remoteRelativePath: fullVersion.relativePath }); - // If the path changed, remove the old entry - if (actualPath !== currentPath) { - this.queue.removeDocument(currentPath); - } - await this.updateCache( fullVersion.vaultUpdateId, responseBytes, - actualPath + targetPath ); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: - actualPath !== currentPath + targetPath !== currentPath ? { - type: SyncType.MOVE, - relativePath: actualPath, - movedFrom: currentPath - } + type: SyncType.MOVE, + relativePath: targetPath, + movedFrom: currentPath + } : { - type: SyncType.UPDATE, - relativePath: actualPath - }, + type: SyncType.UPDATE, + relativePath: targetPath + }, message: "Successfully downloaded remotely updated file from the server", author: fullVersion.userId, @@ -750,17 +838,22 @@ export class Syncer { return; } - const deconflictedPath = await this.operations.ensureClearPath( - remoteVersion.relativePath - ); - if (deconflictedPath !== undefined) { - // The displaced file was moved to a deconflicted path. - // Remove its document record so the offline scan treats - // it as a new file rather than an existing document that - // needs its path synced (which would create duplicates) - this.queue.removeDocument(deconflictedPath); + // Special case: local has an *unsynced* new file at the same path. + // The client must cancel the outgoing Create and merge the two files + // instead of displacing the local one to a conflict path — those + // files are semantically "the same user-intended document" that two + // devices created concurrently, so we want to preserve both sides' + // edits, not shelve one aside. + if (this.queue.hasPendingCreateAt(remoteVersion.relativePath)) { + await this.mergeUnsyncedLocalWithRemoteCreate( + remoteVersion, + contentBytes + ); + return; } + await this.operations.ensureClearPath(remoteVersion.relativePath); + const contentHash = await hash(contentBytes); this.queue.setDocument(remoteVersion.relativePath, { documentId: remoteVersion.documentId, @@ -794,6 +887,131 @@ export class Syncer { }); } + // A remote create landed at a path where we have an unsynced local + // create. How we resolve depends on whether both sides are mergeable + // text: text gets an in-place union merge and one follow-up update; + // binary falls through to displacement so *both* files survive. + private async mergeUnsyncedLocalWithRemoteCreate( + remoteVersion: DocumentVersionWithoutContent, + remoteContent: Uint8Array + ): Promise { + const path = remoteVersion.relativePath; + const localContent = await this.operations.read(path); + + const canMergeText = + isFileTypeMergable( + path, + (await this.serverConfig.getConfig()).mergeableFileExtensions + ) && + !isBinary(localContent) && + !isBinary(remoteContent); + + if (!canMergeText) { + // Binary (or non-mergeable) concurrent creates: leave the local + // Create in the queue and let the default displacement flow + // take over (local bytes are moved to `conflict--…` by + // `ensureClearPath`, remote bytes take `path`). When the Create + // eventually fires it reads the remote content at `path` — not + // what we want — so cancel *just* the Create event and + // re-enqueue a fresh one sourced from the displaced path, so + // the server receives the user's original bytes and dedupes + // the path on its own. + this.queue.cancelPendingCreate(path); + + // `ensureClearPath` may return `undefined` if the file was + // deleted between `read(path)` above and this call (a TOCTOU + // race with a concurrent filesystem delete). That's fine: + // nothing to displace means no local bytes to preserve, and + // we just proceed with the remote content. + const conflictPath = + await this.operations.ensureClearPath(path); + + this.queue.setDocument(path, { + documentId: remoteVersion.documentId, + parentVersionId: remoteVersion.vaultUpdateId, + remoteHash: await hash(remoteContent), + remoteRelativePath: path + }); + await this.operations.create(path, remoteContent); + await this.updateCache( + remoteVersion.vaultUpdateId, + remoteContent, + path + ); + + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: { + type: SyncType.CREATE, + relativePath: path + }, + message: + conflictPath !== undefined + ? `Adopted remote create at ${path}; unsynced local bytes preserved at ${conflictPath} for manual recovery` + : `Adopted remote create at ${path}; local file had already been removed`, + author: remoteVersion.userId, + timestamp: new Date(remoteVersion.updatedDate) + }); + return; + } + + // Mergeable text: union-merge with empty parent (every byte in + // either side is treated as an insertion), overwrite disk, and + // push the merged result to the server if it diverged from the + // remote copy. Cancelling the Create and re-emitting as a + // SyncLocal update lets the existing merge-response pipeline + // handle parentVersionId/content reconciliation end-to-end. + this.queue.cancelPendingCreate(path); + + const mergedContent = new TextEncoder().encode( + reconcile( + "", + new TextDecoder().decode(localContent), + new TextDecoder().decode(remoteContent) + ).text + ); + + // Adopt the remote document's identity locally *before* touching + // disk so an interleaved event can't mistake the file for a fresh + // create again. `remoteHash` is deliberately the server's content + // hash (not the merged one) so the SyncLocal below sees a real + // diff and actually uploads the merge. + const remoteHash = await hash(remoteContent); + this.queue.setDocument(path, { + documentId: remoteVersion.documentId, + parentVersionId: remoteVersion.vaultUpdateId, + remoteHash, + remoteRelativePath: path + }); + + // Overwrite disk with the merged result. We pass `localContent` as + // the "expected" content so `operations.write`'s internal 3-way + // merge is a no-op (expected == disk ⇒ apply `new` verbatim). + await this.operations.write(path, localContent, mergedContent); + + await this.updateCache( + remoteVersion.vaultUpdateId, + remoteContent, + path + ); + + const mergedHash = await hash(mergedContent); + if (mergedHash !== remoteHash) { + this.syncLocallyUpdatedFile({ relativePath: path }); + } + + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: { + type: SyncType.CREATE, + relativePath: path + }, + message: "Merged unsynced local file with concurrent remote create", + author: remoteVersion.userId, + timestamp: new Date(remoteVersion.updatedDate) + }); + } + private async sendUpdate( @@ -834,96 +1052,139 @@ export class Syncer { path, response, contentHash, - originalContentBytes + originalContentBytes, + createEvent }: { path: RelativePath; response: DocumentUpdateResponse; contentHash: string; originalContentBytes: Uint8Array; + // When processing a Create, pass the originating event so its + // `resolvers` promise can be fulfilled (or rejected, on a deleted + // response). Dependent SyncLocal/Delete events are chained through + // that promise and would otherwise `await` forever. + createEvent?: Extract; }): Promise { if (response.isDeleted) { + // A Create that the server returned as already-deleted means + // nothing we can sync — reject the waiting promise so chained + // Delete / SyncLocal events skip themselves instead of hanging. + if (createEvent?.resolvers !== undefined) { + createEvent.resolvers.promise.catch(() => { + /* suppressed — consumer may not be listening */ + }); + createEvent.resolvers.reject( + new Error( + "Create was cancelled — server reported the document as deleted" + ) + ); + } + + // Capture the documentId of the record we *believe* is at + // `path` now. If a concurrent `syncRemotelyChangedPath` moves + // this document between our exists-check and our read, the + // record at `path` after those awaits may belong to a + // DIFFERENT document. Guard against that. + const originalRecord = + this.queue.getSettledDocumentByPath(path); + const originalDocumentId = originalRecord?.documentId; + // If the local file has been edited, re-create it as a new - // document so local edits survive the remote delete + // document so local edits survive the remote delete — but only + // if nothing else is already queuing a Create for this path, to + // avoid doubling up when offline-change detection races with us. if (await this.operations.exists(path)) { const localBytes = await this.operations.read(path); const localHash = await hash(localBytes); - const record = this.queue.getSettledDocumentByPath(path); - if (record !== undefined && localHash !== record.remoteHash) { + const currentRecord = + this.queue.getSettledDocumentByPath(path); + // Re-verify the record's identity hasn't shifted under us. + if ( + currentRecord !== undefined && + currentRecord.documentId === originalDocumentId && + localHash !== currentRecord.remoteHash && + !this.queue.hasPendingCreateAt(path) + ) { this.queue.removeDocument(path); this.syncLocallyCreatedFile(path); return; } } - await this.operations.delete(path); - this.queue.removeDocument(path); + // Only delete on disk if the record at `path` is still the one + // we expected — if a PathChange moved another doc here, we + // shouldn't delete its file. + const finalRecord = this.queue.getSettledDocumentByPath(path); + if ( + finalRecord === undefined || + finalRecord.documentId === originalDocumentId + ) { + await this.operations.delete(path); + this.queue.removeDocument(path); + } return; } - let actualPath = path; - - // Server may have changed the path (e.g. first-rename-wins conflict) - if (response.relativePath !== path) { - actualPath = response.relativePath; - const displacedPath = await this.operations.move( - path, - response.relativePath - ); - if (displacedPath !== undefined) { - const displacedRecord = - this.queue.getSettledDocumentByPath(displacedPath); - if (displacedRecord !== undefined) { - const displacedBytes = - await this.operations.read(displacedPath); - const displacedHash = await hash(displacedBytes); - if (displacedHash !== displacedRecord.remoteHash) { - this.queue.enqueue({ type: SyncEventType.SyncLocal, path: displacedPath }); - } - } - } - // Remove old path entry; the new path will be set below - this.queue.removeDocument(path); - } + // The response carries content only — path reconciliation is the + // sole responsibility of the `PathChange` WebSocket event, which + // fires independently for renames/dedupes. We therefore always + // record the current local `path` here; an in-flight `PathChange` + // will move the file and fix `remoteRelativePath` if the server + // placed the document somewhere else. + const existingRecord = this.queue.getSettledDocumentByPath(path); + const remoteRelativePath = existingRecord?.remoteRelativePath ?? path; + let record: DocumentRecord; if ("type" in response && response.type === "MergingUpdate") { const responseBytes = base64ToBytes(response.contentBase64); await this.operations.write( - actualPath, + path, originalContentBytes, responseBytes ); // Re-read and re-hash after write (invariant #3) - const afterWriteBytes = await this.operations.read(actualPath); + const afterWriteBytes = await this.operations.read(path); const afterWriteHash = await hash(afterWriteBytes); - this.queue.setDocument(actualPath, { + record = { documentId: response.documentId, parentVersionId: response.vaultUpdateId, remoteHash: afterWriteHash, - remoteRelativePath: response.relativePath - }); + remoteRelativePath + }; // Cache the SERVER's content, not local (invariant #2) await this.updateCache( response.vaultUpdateId, responseBytes, - actualPath + path ); } else { // Fast-forward update: no merge needed - this.queue.setDocument(actualPath, { + record = { documentId: response.documentId, parentVersionId: response.vaultUpdateId, remoteHash: contentHash, - remoteRelativePath: response.relativePath - }); + remoteRelativePath + }; await this.updateCache( response.vaultUpdateId, originalContentBytes, - actualPath + path ); } + + // For a Create, fulfill the resolver promise and replace any + // `documentId: Promise<...>` references in queued Delete/SyncLocal + // events with the now-known string id. For everything else a plain + // `setDocument` is enough — the record's identity was already + // resolved when the Create originally settled. + if (createEvent !== undefined) { + this.queue.resolveCreate(createEvent, record); + } else { + this.queue.setDocument(path, record); + } } private async updateCache( diff --git a/frontend/sync-client/src/utils/conflict-path.test.ts b/frontend/sync-client/src/utils/conflict-path.test.ts new file mode 100644 index 00000000..ba39c238 --- /dev/null +++ b/frontend/sync-client/src/utils/conflict-path.test.ts @@ -0,0 +1,85 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { buildConflictFileName, isConflictPath } from "./conflict-path"; + +describe("buildConflictFileName", () => { + it("truncates to the filesystem byte limit while preserving the extension", () => { + const result = buildConflictFileName(`${"a".repeat(300)}.md`); + assert.ok(Buffer.byteLength(result, "utf8") <= 255); + assert.ok(result.endsWith(".md")); + }); + + it("truncates on a codepoint boundary for multi-byte UTF-8 names", () => { + // "🎉" is 4 bytes in UTF-8; splitting one would yield U+FFFD. + const result = buildConflictFileName(`${"🎉".repeat(100)}.md`); + assert.ok(Buffer.byteLength(result, "utf8") <= 255); + assert.ok(!result.includes("�")); + }); + + it("does not split a ZWJ emoji sequence", () => { + // 👨‍👩‍👧 is one grapheme but 5 code points joined by U+200D. + // A codepoint-only truncation can leave a dangling ZWJ. + const family = "\u{1F468}‍\u{1F469}‍\u{1F467}"; + const result = buildConflictFileName(`${family.repeat(20)}.md`); + assert.ok(Buffer.byteLength(result, "utf8") <= 255); + const stem = result.slice( + "conflict-".length + 36 + 1, + result.length - ".md".length + ); + assert.strictEqual( + stem.length % family.length, + 0, + "stem length must be a whole number of families" + ); + assert.ok( + !stem.endsWith("‍"), + "stem must not end with a dangling ZWJ" + ); + }); + + it("does not split a base character from its combining mark", () => { + // NFD "é" = "e" (U+0065) + combining acute (U+0301): one grapheme, + // two code points. A codepoint-only loop can strand the accent. + const grapheme = "é"; + const result = buildConflictFileName(`${grapheme.repeat(150)}.md`); + assert.ok(Buffer.byteLength(result, "utf8") <= 255); + const stem = result.slice( + "conflict-".length + 36 + 1, + result.length - ".md".length + ); + assert.strictEqual( + stem.length % grapheme.length, + 0, + "stem length must be a whole number of graphemes" + ); + assert.ok( + !stem.endsWith("́") || stem.endsWith(grapheme), + "combining mark must stay attached to its base character" + ); + }); +}); + +describe("isConflictPath", () => { + it("does not misclassify user-authored names that start with `conflict-`", () => { + assert.strictEqual(isConflictPath("conflict-resolution.md"), false); + }); + + it("only inspects the final path segment", () => { + assert.strictEqual( + isConflictPath( + "conflict-12345678-1234-1234-1234-123456789abc-x/note.md" + ), + false + ); + assert.strictEqual( + isConflictPath( + "a/b/conflict-12345678-1234-1234-1234-123456789abc-note.md" + ), + true + ); + }); + + it("round-trips with buildConflictFileName", () => { + assert.strictEqual(isConflictPath(buildConflictFileName("note.md")), true); + }); +}); diff --git a/frontend/sync-client/src/utils/conflict-path.ts b/frontend/sync-client/src/utils/conflict-path.ts new file mode 100644 index 00000000..32c6591c --- /dev/null +++ b/frontend/sync-client/src/utils/conflict-path.ts @@ -0,0 +1,66 @@ +import type { RelativePath } from "../sync-operations/types"; + +// Local-only files displaced by `FileOperations.ensureClearPath` are named +// `conflict--`. The UUID is a full RFC-4122 v4 value so +// a user-authored filename that happens to start with `conflict-` doesn't +// get misclassified. +const CONFLICT_UUID_REGEX = + /^conflict-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-/u; + +// Safe segment length for common filesystems (ext4 / NTFS / APFS all cap +// at 255 bytes). `conflict-<36-char-uuid>-` adds 46 bytes; reserve a few +// extra bytes for a future prefix bump and leave room for multi-byte UTF-8 +// characters in the original name. +const CONFLICT_PREFIX_LEN = "conflict-".length + 36 + 1; +const MAX_SEGMENT_BYTES = 255; +const MAX_ORIGINAL_BYTES = MAX_SEGMENT_BYTES - CONFLICT_PREFIX_LEN - 4; + +export function buildConflictFileName(fileName: string): string { + // Truncate the original name if keeping it whole would bust the + // filesystem's segment-length cap. Preserve the trailing extension + // so the file is still recognizable / openable. + const safeName = truncateFileNameToByteLimit(fileName, MAX_ORIGINAL_BYTES); + return `conflict-${crypto.randomUUID()}-${safeName}`; +} + +function truncateFileNameToByteLimit( + fileName: string, + maxBytes: number +): string { + const encoder = new TextEncoder(); + if (encoder.encode(fileName).byteLength <= maxBytes) return fileName; + + const dotIndex = fileName.lastIndexOf("."); + // Dotfile (starts with "." and nothing else) → no extension to preserve. + const hasExtension = dotIndex > 0; + const extension = hasExtension ? fileName.slice(dotIndex) : ""; + const stem = hasExtension ? fileName.slice(0, dotIndex) : fileName; + + const extensionBytes = encoder.encode(extension).byteLength; + const stemBudget = Math.max(0, maxBytes - extensionBytes); + + // Walk the stem by grapheme cluster so we never split an emoji sequence + // (e.g. ZWJ families, skin-tone modifiers) or a base+combining-mark pair. + const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + let truncatedStem = ""; + let usedBytes = 0; + for (const { segment } of segmenter.segment(stem)) { + const segmentBytes = encoder.encode(segment).byteLength; + if (usedBytes + segmentBytes > stemBudget) break; + truncatedStem += segment; + usedBytes += segmentBytes; + } + return truncatedStem + extension; +} + +/** + * Is `path`'s final segment a conflict-displaced filename? + * + * Any sync code that would otherwise create/update/delete/sync the path + * should short-circuit when this returns true: conflict-displaced files are + * strictly local and must stay invisible to the server. + */ +export function isConflictPath(path: RelativePath): boolean { + const fileName = path.substring(path.lastIndexOf("/") + 1); + return CONFLICT_UUID_REGEX.test(fileName); +} diff --git a/sync-server/src/app_state/database.rs b/sync-server/src/app_state/database.rs index e8c02b31..83d2561c 100644 --- a/sync-server/src/app_state/database.rs +++ b/sync-server/src/app_state/database.rs @@ -1,5 +1,9 @@ use core::time::Duration; -use std::{collections::HashMap, sync::Arc, sync::atomic::{AtomicU64, Ordering}}; +use std::{ + collections::HashMap, + sync::Arc, + sync::atomic::{AtomicU64, Ordering}, +}; use anyhow::{Context as _, Result}; use log::info; @@ -96,21 +100,21 @@ pub struct WriteTransaction { } impl WriteTransaction { - async fn new(pool: &Pool, write_guard: tokio::sync::OwnedMutexGuard<()>) -> Result { + async fn new( + pool: &Pool, + write_guard: tokio::sync::OwnedMutexGuard<()>, + ) -> Result { let mut conn = pool .acquire() .await .context("Cannot acquire connection for write transaction")?; - if let Err(e) = sqlx::query("BEGIN IMMEDIATE") - .execute(&mut *conn) - .await - { + if let Err(e) = sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await { let is_busy = match &e { sqlx::Error::Database(db_err) => { // SQLITE_BUSY base code is 5. Extended codes share base 5. - let busy_by_code = db_err.code().is_some_and(|c| { - c.parse::().is_ok_and(|n| n & 0xFF == 5) - }); + let busy_by_code = db_err + .code() + .is_some_and(|c| c.parse::().is_ok_and(|n| n & 0xFF == 5)); busy_by_code || db_err.message().contains("database is locked") } _ => false, @@ -120,7 +124,10 @@ impl WriteTransaction { } return Err(e).context("Cannot begin immediate transaction"); } - Ok(Self { conn: Some(conn), _write_guard: write_guard }) + Ok(Self { + conn: Some(conn), + _write_guard: write_guard, + }) } pub async fn commit(mut self) -> Result<()> { @@ -215,10 +222,7 @@ impl Database { Ok(vaults) } - pub async fn get_vault_stats( - &self, - vault: &VaultId, - ) -> Result { + pub async fn get_vault_stats(&self, vault: &VaultId) -> Result { let pool = self.get_connection_pool(vault).await?; let row = sqlx::query!( r#" @@ -295,10 +299,7 @@ impl Database { Ok(database) } - async fn create_vault_database( - config: &DatabaseConfig, - vault: &VaultId, - ) -> Result { + async fn create_vault_database(config: &DatabaseConfig, vault: &VaultId) -> Result { let file_name = config .databases_directory_path .join(format!("{vault}.sqlite")); @@ -384,7 +385,6 @@ impl Database { Ok(VaultPools { reader, writer }) } - fn validate_vault_id(vault: &VaultId) -> Result<()> { if vault.is_empty() { anyhow::bail!("Vault ID must not be empty"); @@ -427,12 +427,12 @@ impl Database { let vault_clone = vault.clone(); let pools = vault_pool .cell - .get_or_try_init(|| async { - Self::create_vault_database(&config, &vault_clone).await - }) + .get_or_try_init(|| async { Self::create_vault_database(&config, &vault_clone).await }) .await?; - vault_pool.last_accessed_ms.store(self.now_ms(), Ordering::Relaxed); + vault_pool + .last_accessed_ms + .store(self.now_ms(), Ordering::Relaxed); Ok(pools.clone()) } @@ -739,9 +739,6 @@ impl Database { .await .context("Failed to commit transaction")?; - // Both sends are synchronous: there's no `.await` between the - // `commit()` above and function return, so a task cancellation - // can't drop the broadcast and leave peers permanently behind. if broadcast.content_changed { // Content events are filtered out for the origin device — the // origin already has the content (or learns about the merge @@ -945,7 +942,11 @@ impl Database { let closures: Vec<_> = idle_pools .into_iter() .filter_map(|(vault_id, vault_pool)| { - vault_pool.cell.get().cloned().map(|pools| (vault_id, pools)) + vault_pool + .cell + .get() + .cloned() + .map(|pools| (vault_id, pools)) }) .collect(); @@ -958,8 +959,7 @@ impl Database { let writer_clone = pools.writer.clone(); let ckpt_result = tokio::task::spawn_blocking(move || { futures::executor::block_on( - sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") - .execute(&writer_clone), + sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)").execute(&writer_clone), ) }) .await; diff --git a/sync-server/src/app_state/websocket/models.rs b/sync-server/src/app_state/websocket/models.rs index 97247229..282aa03a 100644 --- a/sync-server/src/app_state/websocket/models.rs +++ b/sync-server/src/app_state/websocket/models.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; use crate::app_state::database::models::{ - DeviceId, DocumentId, DocumentVersionWithoutContent, VaultUpdateId, + DeviceId, DocumentId, DocumentVersionWithoutContent, UserId, VaultUpdateId, }; #[derive(TS, Deserialize, Clone, Debug)] @@ -22,6 +22,7 @@ pub struct CursorPositionFromClient { } #[derive(TS, Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] pub struct DocumentWithCursors { // It's None in case the document is dirty. // We still want to sync the cursor to mark diff --git a/sync-server/src/server/delete_document.rs b/sync-server/src/server/delete_document.rs index 3057bd6e..aeec13d3 100644 --- a/sync-server/src/server/delete_document.rs +++ b/sync-server/src/server/delete_document.rs @@ -7,7 +7,7 @@ use axum_extra::TypedHeader; use log::{debug, info}; use serde::Deserialize; -use super::{device_id_header::DeviceIdHeader, requests::DeleteDocumentVersion}; +use super::device_id_header::DeviceIdHeader; use crate::{ app_state::{ AppState, @@ -38,7 +38,6 @@ pub async fn delete_document( Extension(user): Extension, TypedHeader(device_id): TypedHeader, State(state): State, - Json(_request): Json, ) -> Result, SyncServerError> { debug!("Deleting document `{document_id}` in vault `{vault_id}`"); diff --git a/sync-server/src/server/requests.rs b/sync-server/src/server/requests.rs index 250c65d7..f0499194 100644 --- a/sync-server/src/server/requests.rs +++ b/sync-server/src/server/requests.rs @@ -41,5 +41,3 @@ pub struct UpdateTextDocumentVersion { pub content: Vec, } -#[derive(Debug, Deserialize)] -pub struct DeleteDocumentVersion {} diff --git a/sync-server/src/utils/sanitize_path.rs b/sync-server/src/utils/sanitize_path.rs index e8a2a335..46dcc64c 100644 --- a/sync-server/src/utils/sanitize_path.rs +++ b/sync-server/src/utils/sanitize_path.rs @@ -1,9 +1,20 @@ use anyhow::{Result, ensure}; +use crate::consts::MAX_RELATIVE_PATH_LEN; + /// Sanitize the document's path to allow all clients to create the same path in /// their filesystem. If we didn't do this server-side, client's would need to /// deal with mapping invalid names to valid ones and then back. pub fn sanitize_path(path: &str) -> Result { + // Enforce the length cap at the single chokepoint every create/update + // handler goes through, so clients can't blow up axum's JSON/multipart + // parser with a 1 MB `relative_path` before the handler ever runs. + // The WebSocket cursor handler enforces this separately. + ensure!( + path.len() <= MAX_RELATIVE_PATH_LEN, + "Relative path exceeds the maximum length of {MAX_RELATIVE_PATH_LEN} bytes" + ); + let options = sanitize_filename::Options { truncate: true, windows: true, // Windows is the lowest common denominator From a7b588da9729fd089bef2089b75ed19871e8f4b9 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Thu, 23 Apr 2026 21:14:29 +0100 Subject: [PATCH 08/52] fmt --- .../src/lib/types/WebSocketVaultPathChange.ts | 2 +- .../types/WebSocketVaultPathChange.ts | 2 +- sync-server/src/app_state/database.rs | 9 ++--- .../src/app_state/websocket/broadcasts.rs | 6 +-- sync-server/src/app_state/websocket/models.rs | 2 +- sync-server/src/app_state/websocket/utils.rs | 6 +-- sync-server/src/config/database_config.rs | 2 +- sync-server/src/config/logging_config.rs | 9 ++++- sync-server/src/errors.rs | 9 +++-- sync-server/src/server/requests.rs | 1 - .../src/server/restore_document_version.rs | 4 +- sync-server/src/server/websocket.rs | 38 +++++++++---------- .../src/utils/find_first_available_path.rs | 1 - sync-server/src/utils/sanitize_path.rs | 5 ++- 14 files changed, 48 insertions(+), 48 deletions(-) diff --git a/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts b/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts index a0af0a7b..6ae24f75 100644 --- a/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts +++ b/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type WebSocketVaultPathChange = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, userId: string, deviceId: string, }; +export type WebSocketVaultPathChange = { vaultUpdateId: number, documentId: string, relativePath: string, }; diff --git a/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts b/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts index f4b5bb84..f59ca5a5 100644 --- a/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts +++ b/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface WebSocketVaultPathChange { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, userId: string, deviceId: string, } +export interface WebSocketVaultPathChange { vaultUpdateId: number, documentId: string, relativePath: string, } diff --git a/sync-server/src/app_state/database.rs b/sync-server/src/app_state/database.rs index 83d2561c..c9ea9746 100644 --- a/sync-server/src/app_state/database.rs +++ b/sync-server/src/app_state/database.rs @@ -14,7 +14,7 @@ use sqlx::{ConnectOptions, Connection, sqlite::SqliteConnectOptions, types::chro pub mod models; -/// Sentinel error indicating the SQLite database is busy (SQLITE_BUSY). +/// Sentinel error indicating the `SQLite` database is busy (`SQLITE_BUSY`). /// Handlers can downcast to this to return 429 instead of 500. #[derive(Debug, thiserror::Error)] #[error("Database is busy")] @@ -76,11 +76,11 @@ pub struct Database { config: DatabaseConfig, broadcasts: Broadcasts, connection_pools: Arc>>>, - /// Per-vault write serialization. SQLite allows only one writer at a + /// Per-vault write serialization. `SQLite` allows only one writer at a /// time; `BEGIN IMMEDIATE` on a second connection blocks until the first /// commits (up to `busy_timeout`). Under concurrent load the blocked /// connections consume the pool, starving even read-only requests. - /// This mutex moves the wait from the SQLite layer (where it holds a + /// This mutex moves the wait from the `SQLite` layer (where it holds a /// pool connection) to the Tokio layer (where it holds nothing). write_locks: Arc>>>>, /// Monotonic epoch for lock-free `last_accessed_ms` timestamps @@ -768,9 +768,6 @@ impl Database { vault_update_id: version.vault_update_id, document_id: version.document_id, relative_path: version.relative_path.clone(), - updated_date: version.updated_date, - user_id: version.user_id.clone(), - device_id: version.device_id.clone(), }, )), ); diff --git a/sync-server/src/app_state/websocket/broadcasts.rs b/sync-server/src/app_state/websocket/broadcasts.rs index 0b49fa27..fcd4e9dd 100644 --- a/sync-server/src/app_state/websocket/broadcasts.rs +++ b/sync-server/src/app_state/websocket/broadcasts.rs @@ -82,11 +82,7 @@ impl Broadcasts { /// Synchronous: safe to invoke from a handler between `commit()` and /// function return without worrying about task cancellation dropping /// the broadcast mid-flight. Failures are logged, never propagated. - pub fn send_document_update( - &self, - vault: VaultId, - document: WebSocketServerMessageWithOrigin, - ) { + pub fn send_document_update(&self, vault: VaultId, document: WebSocketServerMessageWithOrigin) { let mut tx_map = self .tx .lock() diff --git a/sync-server/src/app_state/websocket/models.rs b/sync-server/src/app_state/websocket/models.rs index 282aa03a..9ed52400 100644 --- a/sync-server/src/app_state/websocket/models.rs +++ b/sync-server/src/app_state/websocket/models.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; use crate::app_state::database::models::{ - DeviceId, DocumentId, DocumentVersionWithoutContent, UserId, VaultUpdateId, + DeviceId, DocumentId, DocumentVersionWithoutContent, VaultUpdateId, }; #[derive(TS, Deserialize, Clone, Debug)] diff --git a/sync-server/src/app_state/websocket/utils.rs b/sync-server/src/app_state/websocket/utils.rs index 24bc287a..4c959a76 100644 --- a/sync-server/src/app_state/websocket/utils.rs +++ b/sync-server/src/app_state/websocket/utils.rs @@ -33,9 +33,9 @@ pub fn get_authenticated_handshake( let user = auth(state, handshake.token.trim(), vault_id)?; Ok(AuthenticatedWebSocketHandshake { handshake, user }) } - WebSocketClientMessage::CursorPositions(_) => Err( - unauthenticated_error(anyhow::anyhow!("Expected a handshake message")), - ), + WebSocketClientMessage::CursorPositions(_) => Err(unauthenticated_error( + anyhow::anyhow!("Expected a handshake message"), + )), } } else { Err(unauthenticated_error(anyhow::anyhow!( diff --git a/sync-server/src/config/database_config.rs b/sync-server/src/config/database_config.rs index 21e79d29..a6f57e1f 100644 --- a/sync-server/src/config/database_config.rs +++ b/sync-server/src/config/database_config.rs @@ -38,7 +38,7 @@ fn default_cursor_timeout() -> Duration { impl DatabaseConfig { pub fn validate(&self) -> Result<()> { ensure!( - self.databases_directory_path.as_os_str().len() > 0, + !self.databases_directory_path.as_os_str().is_empty(), "databases_directory_path must not be empty" ); ensure!( diff --git a/sync-server/src/config/logging_config.rs b/sync-server/src/config/logging_config.rs index 016dbc46..dae67288 100644 --- a/sync-server/src/config/logging_config.rs +++ b/sync-server/src/config/logging_config.rs @@ -5,7 +5,9 @@ use log::debug; use serde::{Deserialize, Serialize}; use crate::{ - consts::{DEFAULT_LOG_DIRECTORY, DEFAULT_LOG_LEVEL, DEFAULT_LOG_ROTATION_INTERVAL, DURATION_ZERO}, + consts::{ + DEFAULT_LOG_DIRECTORY, DEFAULT_LOG_LEVEL, DEFAULT_LOG_ROTATION_INTERVAL, DURATION_ZERO, + }, utils::log_level::LogLevel, }; @@ -27,7 +29,10 @@ impl LoggingConfig { !self.log_directory.is_empty(), "log_directory must not be an empty string" ); - ensure!(self.log_rotation > DURATION_ZERO, "log_rotation must be greater than 0"); + ensure!( + self.log_rotation > DURATION_ZERO, + "log_rotation must be greater than 0" + ); Ok(()) } } diff --git a/sync-server/src/errors.rs b/sync-server/src/errors.rs index 0dad0463..892db36f 100644 --- a/sync-server/src/errors.rs +++ b/sync-server/src/errors.rs @@ -98,9 +98,7 @@ impl IntoResponse for SyncServerError { Self::NotFound(_) => (StatusCode::NOT_FOUND, body).into_response(), Self::Unauthenticated(_) => (StatusCode::UNAUTHORIZED, body).into_response(), Self::PermissionDeniedError(_) => (StatusCode::FORBIDDEN, body).into_response(), - Self::TooManyRequests(_) => { - (StatusCode::TOO_MANY_REQUESTS, body).into_response() - } + Self::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, body).into_response(), } } } @@ -171,7 +169,10 @@ pub fn too_many_requests_error(error: anyhow::Error) -> SyncServerError { /// Maps a `create_write_transaction` error to 429 if the database is busy, /// or 500 for all other failures. pub fn write_transaction_error(error: anyhow::Error) -> SyncServerError { - if error.downcast_ref::().is_some() { + if error + .downcast_ref::() + .is_some() + { too_many_requests_error(error) } else { server_error(error) diff --git a/sync-server/src/server/requests.rs b/sync-server/src/server/requests.rs index f0499194..e0468edc 100644 --- a/sync-server/src/server/requests.rs +++ b/sync-server/src/server/requests.rs @@ -40,4 +40,3 @@ pub struct UpdateTextDocumentVersion { #[ts(type = "Array")] pub content: Vec, } - diff --git a/sync-server/src/server/restore_document_version.rs b/sync-server/src/server/restore_document_version.rs index 7522e73c..5a806edd 100644 --- a/sync-server/src/server/restore_document_version.rs +++ b/sync-server/src/server/restore_document_version.rs @@ -20,7 +20,9 @@ use crate::{ }, }, config::user_config::User, - errors::{SyncServerError, client_error, not_found_error, server_error, write_transaction_error}, + errors::{ + SyncServerError, client_error, not_found_error, server_error, write_transaction_error, + }, utils::{find_first_available_path::find_first_available_path, normalize::normalize}, }; diff --git a/sync-server/src/server/websocket.rs b/sync-server/src/server/websocket.rs index 41bf4754..4540539a 100644 --- a/sync-server/src/server/websocket.rs +++ b/sync-server/src/server/websocket.rs @@ -1,15 +1,3 @@ -use anyhow::Context; -use axum::{ - extract::{ - Path, State, - ws::{Message, WebSocket, WebSocketUpgrade}, - }, - response::Response, -}; -use futures::sink::SinkExt; -use futures::stream::StreamExt; -use log::{debug, info, warn}; -use serde::Deserialize; use crate::{ app_state::{ AppState, @@ -25,12 +13,23 @@ use crate::{ }, }, consts::{ - HANDSHAKE_TIMEOUT, MAX_CURSORS_PER_DOCUMENT, MAX_CURSOR_DOCUMENTS, - MAX_RELATIVE_PATH_LEN, + HANDSHAKE_TIMEOUT, MAX_CURSOR_DOCUMENTS, MAX_CURSORS_PER_DOCUMENT, MAX_RELATIVE_PATH_LEN, }, errors::{SyncServerError, client_error, server_error}, utils::normalize::normalize, }; +use anyhow::Context; +use axum::{ + extract::{ + Path, State, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + response::Response, +}; +use futures::sink::SinkExt; +use futures::stream::StreamExt; +use log::{debug, info, warn}; +use serde::Deserialize; /// Tracks a pending (not yet authenticated) WebSocket connection. /// Decrements the counter when dropped, ensuring cleanup even if @@ -39,8 +38,7 @@ struct PendingWsGuard(std::sync::Arc); impl Drop for PendingWsGuard { fn drop(&mut self) { - self.0 - .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); } } @@ -112,9 +110,7 @@ async fn websocket( drop(pending_guard); let max_clients = state.config.server.max_clients_per_vault; - let mut broadcast_receiver = match state - .broadcasts - .get_receiver(vault_id.clone(), max_clients) + let mut broadcast_receiver = match state.broadcasts.get_receiver(vault_id.clone(), max_clients) { Ok(receiver) => receiver, Err(err) => { @@ -229,7 +225,9 @@ async fn websocket( && doc.relative_path.len() <= MAX_RELATIVE_PATH_LEN }); if !valid { - warn!("Cursor update rejected: a document exceeds cursor or path length limits"); + warn!( + "Cursor update rejected: a document exceeds cursor or path length limits" + ); continue; } diff --git a/sync-server/src/utils/find_first_available_path.rs b/sync-server/src/utils/find_first_available_path.rs index caaa1624..eddd81d2 100644 --- a/sync-server/src/utils/find_first_available_path.rs +++ b/sync-server/src/utils/find_first_available_path.rs @@ -4,7 +4,6 @@ use anyhow::Result; use log::{debug, info}; use sqlx::sqlite::SqliteConnection; - pub async fn find_first_available_path( vault_id: &VaultId, sanitized_relative_path: &str, diff --git a/sync-server/src/utils/sanitize_path.rs b/sync-server/src/utils/sanitize_path.rs index 46dcc64c..05100f68 100644 --- a/sync-server/src/utils/sanitize_path.rs +++ b/sync-server/src/utils/sanitize_path.rs @@ -34,7 +34,10 @@ pub fn sanitize_path(path: &str) -> Result { .collect::>() .join("/"); - ensure!(!result.is_empty(), "Relative path is empty after sanitization"); + ensure!( + !result.is_empty(), + "Relative path is empty after sanitization" + ); Ok(result) } From 19d5dc19993d4f6ca292910bcc11df533d5efa30 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 24 Apr 2026 20:56:03 +0100 Subject: [PATCH 09/52] . --- .../file-operations/file-operations.test.ts | 2 +- .../src/file-operations/file-operations.ts | 2 +- frontend/sync-client/src/sync-client.ts | 8 +- .../conflict-path.test.ts | 0 .../conflict-path.ts | 2 +- .../offline-change-detector.ts | 4 +- .../sync-operations/sync-event-queue.test.ts | 182 +++++----- .../src/sync-operations/sync-event-queue.ts | 158 +++++---- .../sync-client/src/sync-operations/syncer.ts | 316 +++++++++--------- .../sync-client/src/sync-operations/types.ts | 36 +- sync-server/src/app_state/websocket/models.rs | 3 + 11 files changed, 358 insertions(+), 355 deletions(-) rename frontend/sync-client/src/{utils => sync-operations}/conflict-path.test.ts (100%) rename frontend/sync-client/src/{utils => sync-operations}/conflict-path.ts (97%) diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index 78977b14..5a8f5af6 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -8,7 +8,7 @@ import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly"; import type { FileSystemOperations } from "./filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; import type { ServerConfig, ServerConfigData } from "../services/server-config"; -import { isConflictPath } from "../utils/conflict-path"; +import { isConflictPath } from "../sync-operations/conflict-path"; class MockServerConfig implements Pick { public async getConfig(): Promise { diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 3b3d50c4..e2ffd4a5 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -7,7 +7,7 @@ import type { TextWithCursors } from "reconcile-text"; import { reconcile } from "reconcile-text"; import { isFileTypeMergable } from "../utils/is-file-type-mergable"; import { isBinary } from "../utils/is-binary"; -import { buildConflictFileName } from "../utils/conflict-path"; +import { buildConflictFileName } from "../sync-operations/conflict-path"; import type { ServerConfig } from "../services/server-config"; export class FileOperations { diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index ff1c3841..39b0f000 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -57,8 +57,8 @@ export class SyncClient { > ) { } - public get documentCount(): number { - return this.syncEventQueue.documentCount; + public get syncedDocumentCount(): number { + return this.syncEventQueue.syncedDocumentCount; } public get isWebSocketConnected(): boolean { @@ -390,7 +390,7 @@ export class SyncClient { public get hasPendingWork(): boolean { return ( - this.syncEventQueue.size > 0 || + this.syncEventQueue.pendingUpdateCount > 0 || this.webSocketManager.hasOutstandingWork ); } @@ -408,7 +408,7 @@ export class SyncClient { return DocumentSyncStatus.SYNCING; } - return this.syncer.hasPendingOperationsForDocument(relativePath) + return this.syncEventQueue.hasPendingEventsForPath(relativePath) ? DocumentSyncStatus.SYNCING : DocumentSyncStatus.UP_TO_DATE; } diff --git a/frontend/sync-client/src/utils/conflict-path.test.ts b/frontend/sync-client/src/sync-operations/conflict-path.test.ts similarity index 100% rename from frontend/sync-client/src/utils/conflict-path.test.ts rename to frontend/sync-client/src/sync-operations/conflict-path.test.ts diff --git a/frontend/sync-client/src/utils/conflict-path.ts b/frontend/sync-client/src/sync-operations/conflict-path.ts similarity index 97% rename from frontend/sync-client/src/utils/conflict-path.ts rename to frontend/sync-client/src/sync-operations/conflict-path.ts index 32c6591c..9e107b9a 100644 --- a/frontend/sync-client/src/utils/conflict-path.ts +++ b/frontend/sync-client/src/sync-operations/conflict-path.ts @@ -1,4 +1,4 @@ -import type { RelativePath } from "../sync-operations/types"; +import type { RelativePath } from "./types"; // Local-only files displaced by `FileOperations.ensureClearPath` are named // `conflict--`. The UUID is a full RFC-4122 v4 value so diff --git a/frontend/sync-client/src/sync-operations/offline-change-detector.ts b/frontend/sync-client/src/sync-operations/offline-change-detector.ts index e6bc2b51..c90f6a78 100644 --- a/frontend/sync-client/src/sync-operations/offline-change-detector.ts +++ b/frontend/sync-client/src/sync-operations/offline-change-detector.ts @@ -89,7 +89,7 @@ function enqueueRenamedDocuments( const hasLocalRename = remoteRelPath !== undefined && remoteRelPath !== path; if (hasLocalRename) { - queue.enqueue({ type: SyncEventType.SyncLocal, path }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path }); locallyRenamedPaths.add(path); logger.debug(`Document ${path} was renamed locally (from ${remoteRelPath}), scheduling sync`); } @@ -243,5 +243,5 @@ async function handleNewFile( } logger.debug(`Document ${relativePath} not found in database, scheduling sync to create it`); - return { instruction: { type: SyncEventType.Create, relativePath } }; + return { instruction: { type: SyncEventType.LocalCreate, relativePath } }; } diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts index aae010b6..a33aa258 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts @@ -38,13 +38,13 @@ describe("SyncEventQueue", () => { remoteHash: "hash-a" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.Delete); - if (event?.type === SyncEventType.Delete) { + assert.strictEqual(event?.type, SyncEventType.LocalDelete); + if (event?.type === SyncEventType.LocalDelete) { assert.strictEqual(event.documentId, "A"); } assert.strictEqual(await queue.next(), undefined); @@ -58,34 +58,34 @@ describe("SyncEventQueue", () => { remoteHash: "hash-a" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.SyncLocal); + assert.strictEqual(event?.type, SyncEventType.LocalUpdate); assert.strictEqual(await queue.next(), undefined); }); - it("sync-remote events for the same documentId coalesce to the last one", async () => { + it("sync-remote-content events for the same documentId coalesce to the last one", async () => { const queue = createQueue(); queue.enqueue({ - type: SyncEventType.SyncRemote, + type: SyncEventType.RemoteUpdate, remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 1 }) }); queue.enqueue({ - type: SyncEventType.SyncRemote, + type: SyncEventType.RemoteUpdate, remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 2 }) }); queue.enqueue({ - type: SyncEventType.SyncRemote, + type: SyncEventType.RemoteUpdate, remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 3 }) }); const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.SyncRemote); - if (event?.type === SyncEventType.SyncRemote) { + assert.strictEqual(event?.type, SyncEventType.RemoteUpdate); + if (event?.type === SyncEventType.RemoteUpdate) { assert.strictEqual(event.remoteVersion.vaultUpdateId, 3); } assert.strictEqual(await queue.next(), undefined); @@ -93,18 +93,18 @@ describe("SyncEventQueue", () => { it("create events are returned FIFO", async () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - queue.enqueue({ type: SyncEventType.Create, path: "b.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); const first = await queue.next(); - assert.strictEqual(first?.type, SyncEventType.Create); - if (first?.type === SyncEventType.Create) { + assert.strictEqual(first?.type, SyncEventType.LocalCreate); + if (first?.type === SyncEventType.LocalCreate) { assert.strictEqual(first.path, "a.md"); } const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.Create); - if (second?.type === SyncEventType.Create) { + assert.strictEqual(second?.type, SyncEventType.LocalCreate); + if (second?.type === SyncEventType.LocalCreate) { assert.strictEqual(second.path, "b.md"); } }); @@ -117,33 +117,33 @@ describe("SyncEventQueue", () => { remoteHash: "hash-a" }); - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.Delete); - if (event?.type === SyncEventType.Delete) { + assert.strictEqual(event?.type, SyncEventType.LocalDelete); + if (event?.type === SyncEventType.LocalDelete) { assert.strictEqual(event.documentId, "A"); } }); it("delete for unknown path is silently ignored", () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Delete, path: "unknown.md" }); - assert.strictEqual(queue.size, 0); + queue.enqueue({ type: SyncEventType.LocalDelete, path: "unknown.md" }); + assert.strictEqual(queue.pendingUpdateCount, 0); }); it("document store CRUD operations work correctly", () => { const queue = createQueue(); assert.strictEqual(queue.getSettledDocumentByPath("a.md"), undefined); - assert.strictEqual(queue.documentCount, 0); + assert.strictEqual(queue.syncedDocumentCount, 0); queue.setDocument("a.md", { documentId: "A", parentVersionId: 1, remoteHash: "hash-a" }); - assert.strictEqual(queue.documentCount, 1); + assert.strictEqual(queue.syncedDocumentCount, 1); assert.deepStrictEqual(queue.getSettledDocumentByPath("a.md"), { documentId: "A", parentVersionId: 1, @@ -155,7 +155,7 @@ describe("SyncEventQueue", () => { assert.strictEqual(found?.record.documentId, "A"); queue.removeDocument("a.md"); - assert.strictEqual(queue.documentCount, 0); + assert.strictEqual(queue.syncedDocumentCount, 0); assert.strictEqual(queue.getSettledDocumentByPath("a.md"), undefined); }); @@ -167,7 +167,7 @@ describe("SyncEventQueue", () => { remoteHash: "hash-a" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "b.md", oldPath: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "b.md", oldPath: "a.md" }); assert.strictEqual(queue.getSettledDocumentByPath("a.md"), undefined); assert.strictEqual(queue.getSettledDocumentByPath("b.md")?.documentId, "A"); }); @@ -185,29 +185,29 @@ describe("SyncEventQueue", () => { remoteHash: "hash-b" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "b.md" }); - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "b.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "b.md" }); + queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "b.md" }); // First next() should see the delete for A (coalescing sync-local + delete) const first = await queue.next(); - assert.strictEqual(first?.type, SyncEventType.Delete); - if (first?.type === SyncEventType.Delete) { + assert.strictEqual(first?.type, SyncEventType.LocalDelete); + if (first?.type === SyncEventType.LocalDelete) { assert.strictEqual(first.documentId, "A"); } // Remaining should be the coalesced sync-local for B const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.SyncLocal); - if (second?.type === SyncEventType.SyncLocal) { + assert.strictEqual(second?.type, SyncEventType.LocalUpdate); + if (second?.type === SyncEventType.LocalUpdate) { assert.strictEqual(second.documentId, "B"); } assert.strictEqual(await queue.next(), undefined); }); - it("delete discards subsequent sync-remote events for the same document", async () => { + it("delete discards subsequent sync-remote-content events for the same document", async () => { const queue = createQueue(); queue.setDocument("a.md", { documentId: "A", @@ -215,18 +215,18 @@ describe("SyncEventQueue", () => { remoteHash: "hash-a" }); - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); queue.enqueue({ - type: SyncEventType.SyncRemote, + type: SyncEventType.RemoteUpdate, remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 5 }) }); const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.Delete); + assert.strictEqual(event?.type, SyncEventType.LocalDelete); assert.strictEqual(await queue.next(), undefined); }); - it("delete discards subsequent sync-local and sync-remote for the same document", async () => { + it("delete discards subsequent sync-local and sync-remote-content for the same document", async () => { const queue = createQueue(); queue.setDocument("a.md", { documentId: "A", @@ -234,20 +234,20 @@ describe("SyncEventQueue", () => { remoteHash: "hash-a" }); - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.Create, path: "b.md" }); + queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); queue.enqueue({ - type: SyncEventType.SyncRemote, + type: SyncEventType.RemoteUpdate, remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 5 }) }); const first = await queue.next(); - assert.strictEqual(first?.type, SyncEventType.Delete); + assert.strictEqual(first?.type, SyncEventType.LocalDelete); // Only the unrelated create should remain const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.Create); + assert.strictEqual(second?.type, SyncEventType.LocalCreate); assert.strictEqual(await queue.next(), undefined); }); @@ -260,30 +260,30 @@ describe("SyncEventQueue", () => { }); // Create is pending — Delete for same path gets a promise documentId - queue.enqueue({ type: SyncEventType.Create, path: "unknown.md" }); - queue.enqueue({ type: SyncEventType.Delete, path: "unknown.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "unknown.md" }); + queue.enqueue({ type: SyncEventType.LocalDelete, path: "unknown.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); // Dequeue and resolve the Create const event = await queue.next(); - assert.ok(event?.type === SyncEventType.Create); + assert.ok(event?.type === SyncEventType.LocalCreate); event.resolvers!.resolve("NEW"); await queue.next(); // delete const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.SyncLocal); + assert.strictEqual(second?.type, SyncEventType.LocalUpdate); }); it("getCreatePromise returns a promise resolved by the event's resolvers", async () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); const promise = queue.getCreatePromise("a.md"); assert.ok(promise !== undefined); // The syncer resolves via event.resolvers after dequeuing const event = await queue.next(); - assert.ok(event?.type === SyncEventType.Create); + assert.ok(event?.type === SyncEventType.LocalCreate); assert.ok(event.resolvers !== undefined); event.resolvers.resolve("resolved-id"); @@ -292,13 +292,13 @@ describe("SyncEventQueue", () => { it("rejecting the event's resolvers rejects the create promise", async () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); const promise = queue.getCreatePromise("a.md"); assert.ok(promise !== undefined); const event = await queue.next(); - assert.ok(event?.type === SyncEventType.Create); + assert.ok(event?.type === SyncEventType.LocalCreate); assert.ok(event.resolvers !== undefined); event.resolvers.promise.catch(() => { }); event.resolvers.reject(new Error("cancelled")); @@ -308,8 +308,8 @@ describe("SyncEventQueue", () => { it("clear rejects all pending create promises", async () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - queue.enqueue({ type: SyncEventType.Create, path: "b.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); const promiseA = queue.getCreatePromise("a.md"); const promiseB = queue.getCreatePromise("b.md"); @@ -324,28 +324,28 @@ describe("SyncEventQueue", () => { it("create can be re-enqueued after being dequeued", async () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); await queue.next(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - assert.strictEqual(queue.size, 1); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); + assert.strictEqual(queue.pendingUpdateCount, 1); }); it("silently ignores create events matching ignore patterns", () => { const queue = createQueue(["*.tmp", ".hidden/**"]); - queue.enqueue({ type: SyncEventType.Create, path: "scratch.tmp" }); - queue.enqueue({ type: SyncEventType.Create, path: ".hidden/secret.md" }); - assert.strictEqual(queue.size, 0); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "scratch.tmp" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: ".hidden/secret.md" }); + assert.strictEqual(queue.pendingUpdateCount, 0); - queue.enqueue({ type: SyncEventType.Create, path: "notes-new.md" }); - assert.strictEqual(queue.size, 1); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "notes-new.md" }); + assert.strictEqual(queue.pendingUpdateCount, 1); queue.enqueue({ - type: SyncEventType.SyncRemote, + type: SyncEventType.RemoteUpdate, remoteVersion: fakeRemoteVersion("N") }); - assert.strictEqual(queue.size, 2); + assert.strictEqual(queue.pendingUpdateCount, 2); }); it("clear removes events but keeps documents", () => { @@ -355,15 +355,15 @@ describe("SyncEventQueue", () => { parentVersionId: 1, remoteHash: "hash-a" }); - queue.enqueue({ type: SyncEventType.Create, path: "b.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); - assert.strictEqual(queue.size, 2); + assert.strictEqual(queue.pendingUpdateCount, 2); queue.clear(); - assert.strictEqual(queue.size, 0); - assert.strictEqual(queue.documentCount, 1); + assert.strictEqual(queue.pendingUpdateCount, 0); + assert.strictEqual(queue.syncedDocumentCount, 1); assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "A"); }); @@ -407,7 +407,7 @@ describe("SyncEventQueue", () => { lastSeenUpdateId: 4 }, async () => { }); - assert.strictEqual(queue.documentCount, 2); + assert.strictEqual(queue.syncedDocumentCount, 2); assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "A"); assert.strictEqual(queue.getSettledDocumentByPath("b.md")?.documentId, "B"); assert.strictEqual(queue.lastSeenUpdateId, 5); @@ -427,9 +427,9 @@ describe("SyncEventQueue", () => { }); // Pending create adds a path - queue.enqueue({ type: SyncEventType.Create, path: "c.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "c.md" }); // Pending delete removes a path - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); const paths = queue.trackedPaths(); assert.deepStrictEqual( @@ -441,10 +441,10 @@ describe("SyncEventQueue", () => { it("trackedPaths handles create-delete-create for the same path", () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); // Delete gets promise documentId from pending Create - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); const paths = queue.trackedPaths(); assert.ok(paths.has("a.md")); @@ -453,10 +453,10 @@ describe("SyncEventQueue", () => { it("trackedPaths applies moves for pending SyncLocal events", () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); // File was renamed from a.md to b.md - queue.enqueue({ type: SyncEventType.SyncLocal, path: "b.md", oldPath: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "b.md", oldPath: "a.md" }); const paths = queue.trackedPaths(); assert.ok(!paths.has("a.md")); @@ -466,10 +466,10 @@ describe("SyncEventQueue", () => { it("trackedPaths tracks multiple moves for the same pending create", () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "b.md", oldPath: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "c.md", oldPath: "b.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "b.md", oldPath: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "c.md", oldPath: "b.md" }); const paths = queue.trackedPaths(); assert.ok(!paths.has("a.md")); @@ -480,15 +480,15 @@ describe("SyncEventQueue", () => { it("resolveCreate settles the document and replaces promise documentIds in the queue", async () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); const createPromise = queue.getCreatePromise("a.md")!; // Dependent events enqueued while create is still pending - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); + queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); const event = await queue.next(); // dequeue the create - assert.ok(event?.type === SyncEventType.Create); + assert.ok(event?.type === SyncEventType.LocalCreate); queue.resolveCreate(event, { documentId: "DOC-1", @@ -506,7 +506,7 @@ describe("SyncEventQueue", () => { // The SyncLocal + Delete for "DOC-1" coalesce: sync-local is // discarded and the delete is returned (standard coalescing). const deleteEvt = await queue.next(); - assert.ok(deleteEvt?.type === SyncEventType.Delete); + assert.ok(deleteEvt?.type === SyncEventType.LocalDelete); assert.strictEqual(deleteEvt.documentId, "DOC-1"); assert.strictEqual(await queue.next(), undefined); diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index a49ce71f..8a19009a 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -1,7 +1,7 @@ import type { Settings } from "../persistence/settings"; import type { Logger } from "../tracing/logger"; import { globsToRegexes } from "../utils/globs-to-regexes"; -import { isConflictPath } from "../utils/conflict-path"; +import { isConflictPath } from "./conflict-path"; import { removeFromArray } from "../utils/remove-from-array"; import { SyncEventType, @@ -13,6 +13,10 @@ import { type SyncEvent, type VaultUpdateId, } from "./types"; +import { sleep } from "../utils/sleep"; + +export const SAVE_RETRY_BASE_DELAY_MS = 50; +export const SAVE_RETRY_MAX_ATTEMPTS = 3; export class SyncEventQueue { // Latest state of the filesystem as we know it, excluding @@ -27,7 +31,7 @@ export class SyncEventQueue { // can include multiple generations of the same document, // e.g.: a create, delete, create sequence for the same path. // - // The paths for the events must always correspond to the latest + // The paths within the events must always correspond to the latest // path on disk, so the path of each event may be updated multiple // times. // @@ -37,6 +41,11 @@ export class SyncEventQueue { // file creations for paths matching any of these patterns will be ignored private ignorePatterns: RegExp[]; + private savePending = false; + + + private lastSeenUpdateId: VaultUpdateId; + public constructor( private readonly settings: Settings, private readonly logger: Logger, @@ -62,29 +71,19 @@ export class SyncEventQueue { this.documents.set(relativePath, record); } } + this.lastSeenUpdateId = initialState.lastSeenUpdateId ?? -1; - this.logger.debug(`Loaded ${this.documents.size} documents`); + this.logger.debug(`Loaded ${this.documents.size} documents and lastSeenUpdateId=${this.lastSeenUpdateId} from storage`); } - public get size(): number { + public get pendingUpdateCount(): number { return this.events.length; } - public get documentCount(): number { + public get syncedDocumentCount(): number { return this.documents.size; } - public get lastSeenUpdateId(): VaultUpdateId { - let max = 0; - for (const record of this.documents.values()) { - if (record.parentVersionId > max) { - max = record.parentVersionId; - } - } - return max; - } - - // todo: let's remove public getSettledDocumentByPath(path: RelativePath): DocumentRecord | undefined { return this.documents.get(path); @@ -149,7 +148,7 @@ export class SyncEventQueue { this.documents.set(newPath, record); for (const e of this.events) { if ( - e.type === SyncEventType.SyncLocal && + e.type === SyncEventType.LocalUpdate && e.documentId === record.documentId ) { e.path = newPath; @@ -168,7 +167,7 @@ export class SyncEventQueue { * Call once a create has been acknowledged by the server. */ public resolveCreate( - event: Extract, + event: Extract, record: DocumentRecord ): void { const promise = event.resolvers?.promise; @@ -179,7 +178,7 @@ export class SyncEventQueue { if (promise !== undefined) { for (const e of this.events) { if ( - (e.type === SyncEventType.SyncLocal || e.type === SyncEventType.Delete) && + (e.type === SyncEventType.LocalUpdate || e.type === SyncEventType.LocalDelete) && e.documentId === promise ) { (e as { documentId: DocumentId | Promise }).documentId = record.documentId; @@ -211,12 +210,12 @@ export class SyncEventQueue { const pendingPaths = new Map, RelativePath>(); for (const event of this.events) { - if (event.type === SyncEventType.Create) { + if (event.type === SyncEventType.LocalCreate) { paths.add(event.path); if (event.resolvers !== undefined) { pendingPaths.set(event.resolvers.promise, event.path); } - } else if (event.type === SyncEventType.Delete) { + } else if (event.type === SyncEventType.LocalDelete) { if (typeof event.documentId === "string") { const path = this.getDocumentByDocumentId(event.documentId)?.path; if (path) { @@ -244,14 +243,16 @@ export class SyncEventQueue { const docId = record.documentId; return this.events.some( (e) => - (e.type === SyncEventType.Create && e.path === path) || - (e.type === SyncEventType.SyncLocal && + (e.type === SyncEventType.LocalCreate && e.path === path) || + (e.type === SyncEventType.LocalUpdate && e.documentId === docId) || - (e.type === SyncEventType.Delete && + (e.type === SyncEventType.LocalDelete && e.documentId === docId) || - (e.type === SyncEventType.SyncRemote && + (e.type === SyncEventType.RemoteUpdate && // we care about the local path not the remote - this.getDocumentByDocumentId(e.remoteVersion.documentId)?.path === path) + this.getDocumentByDocumentId(e.remoteVersion.documentId)?.path === path) || + (e.type === SyncEventType.RemotePathChange && + this.getDocumentByDocumentId(e.pathChange.documentId)?.path === path) ); } @@ -279,7 +280,10 @@ export class SyncEventQueue { } public enqueue(input: FileSyncEvent): void { - if (input.type === SyncEventType.SyncRemote) { + if ( + input.type === SyncEventType.RemoteUpdate || + input.type === SyncEventType.RemotePathChange + ) { this.events.push(input); return; } @@ -303,19 +307,19 @@ export class SyncEventQueue { return; } - if (input.type === SyncEventType.Create) { - this.events.push({ type: SyncEventType.Create, path, originalPath: path }); + if (input.type === SyncEventType.LocalCreate) { + this.events.push({ type: SyncEventType.LocalCreate, path, originalPath: path }); return; } - const lookupPath = (input.type === SyncEventType.SyncLocal && input.oldPath) ? input.oldPath : path; + const lookupPath = (input.type === SyncEventType.LocalUpdate && input.oldPath) ? input.oldPath : path; const record = this.documents.get(lookupPath); const documentId: DocumentId | Promise | undefined = record?.documentId ?? this.getCreatePromise(lookupPath); if (documentId === undefined) return; - if (input.type === SyncEventType.Delete) { - this.events.push({ type: SyncEventType.Delete, documentId }); + if (input.type === SyncEventType.LocalDelete) { + this.events.push({ type: SyncEventType.LocalDelete, documentId }); return; } @@ -324,7 +328,7 @@ export class SyncEventQueue { this.documents.delete(input.oldPath); this.documents.set(path, record!); for (const e of this.events) { - if (e.type === SyncEventType.SyncLocal && e.documentId === documentId) { + if (e.type === SyncEventType.LocalUpdate && e.documentId === documentId) { e.path = path; } } @@ -333,7 +337,7 @@ export class SyncEventQueue { this.updatePendingCreatePath(input.oldPath, path); } } - this.events.push({ type: SyncEventType.SyncLocal, documentId, path, originalPath: path }); + this.events.push({ type: SyncEventType.LocalUpdate, documentId, path, originalPath: path }); } @@ -344,7 +348,7 @@ export class SyncEventQueue { const [first] = this.events; // Creates are always returned immediately (FIFO) - if (first.type === SyncEventType.Create) { + if (first.type === SyncEventType.LocalCreate) { this.events.shift(); return first; } @@ -355,7 +359,7 @@ export class SyncEventQueue { // `Promise` (the originating Create hasn't landed // yet), awaiting it may reject — handle that: the Create was // cancelled, so the Delete has nothing to delete, just drop it. - if (first.type === SyncEventType.Delete) { + if (first.type === SyncEventType.LocalDelete) { this.events.shift(); const { documentId } = first; let resolvedId: DocumentId; @@ -371,14 +375,14 @@ export class SyncEventQueue { return first; } - if (first.type === SyncEventType.SyncLocal) { + if (first.type === SyncEventType.LocalUpdate) { const { documentId } = first; // If there's a later delete for the same documentId, discard // all sync-locals for that document and return the delete const deleteEvent = this.events.find( (e) => - e.type === SyncEventType.Delete && + e.type === SyncEventType.LocalDelete && e.documentId === documentId ); if (deleteEvent !== undefined) { @@ -399,7 +403,7 @@ export class SyncEventQueue { // original path to the last one const matching = this.events.filter( (e) => - e.type === SyncEventType.SyncLocal && + e.type === SyncEventType.LocalUpdate && e.documentId === documentId && e.originalPath === first.originalPath // can't coalesce moves as they can depend on each other so we have to sync them in the same order, could do topological sort but let's keep it simple for now ); @@ -410,12 +414,31 @@ export class SyncEventQueue { return result; } - // SyncRemote: coalesce multiple events for the same documentId to the last one - const { documentId } = first.remoteVersion; + // Coalesce multiple events of the same remote kind for the same + // documentId to the last one. Kinds are coalesced independently so + // that an interleaved content+path stream (e.g. VaultUpdate → + // PathChange) still preserves the VaultUpdate-before-PathChange + // ordering invariant the syncer relies on. + if (first.type === SyncEventType.RemoteUpdate) { + const { documentId } = first.remoteVersion; + const matching = this.events.filter( + (e) => + e.type === SyncEventType.RemoteUpdate && + e.remoteVersion.documentId === documentId + ); + const result = matching[matching.length - 1]; + for (const item of matching) { + removeFromArray(this.events, item); + } + return result; + } + + // SyncRemotePath + const { documentId } = first.pathChange; const matching = this.events.filter( (e) => - e.type === SyncEventType.SyncRemote && - e.remoteVersion.documentId === documentId + e.type === SyncEventType.RemotePathChange && + e.pathChange.documentId === documentId ); const result = matching[matching.length - 1]; for (const item of matching) { @@ -436,11 +459,13 @@ export class SyncEventQueue { for (let i = this.events.length - 1; i >= 0; i--) { const e = this.events[i]; if ( - (e.type === SyncEventType.SyncLocal && + (e.type === SyncEventType.LocalUpdate && e.documentId === documentId) || - (e.type === SyncEventType.SyncRemote && + (e.type === SyncEventType.RemoteUpdate && e.remoteVersion.documentId === documentId) || - (e.type === SyncEventType.Delete && + (e.type === SyncEventType.RemotePathChange && + e.pathChange.documentId === documentId) || + (e.type === SyncEventType.LocalDelete && e.documentId === documentId) ) { // eslint-disable-next-line no-restricted-syntax -- Bulk removal by predicate, not single-item removal @@ -462,7 +487,7 @@ export class SyncEventQueue { if (promise !== undefined) { for (const e of this.events) { if ( - e.type === SyncEventType.SyncLocal && + e.type === SyncEventType.LocalUpdate && e.documentId === promise ) { e.path = newPath; @@ -477,7 +502,7 @@ export class SyncEventQueue { for (let i = this.events.length - 1; i >= 0; i--) { const e = this.events[i]; if ( - e.type === SyncEventType.Create && + e.type === SyncEventType.LocalCreate && e.resolvers?.promise === promise ) { return e.path; @@ -488,10 +513,10 @@ export class SyncEventQueue { private findLastCreate( path: RelativePath - ): Extract | undefined { + ): Extract | undefined { for (let i = this.events.length - 1; i >= 0; i--) { const e = this.events[i]; - if (e.type === SyncEventType.Create && e.path === path) { + if (e.type === SyncEventType.LocalCreate && e.path === path) { return e; } } @@ -535,52 +560,23 @@ export class SyncEventQueue { private rejectAllPendingCreates(): void { for (const event of this.events) { - if (event.type === SyncEventType.Create && event.resolvers !== undefined) { + if (event.type === SyncEventType.LocalCreate && event.resolvers !== undefined) { event.resolvers.promise.catch(() => { /* suppressed — consumer may not be listening */ }); event.resolvers.reject(new Error("Create was cancelled")); } } } - private savePending = false; // Coalesce bursts of mutations into one persist per microtask. A drain // iteration can easily produce 10+ mutations; without this, we'd fire // 10 overlapping `save()` calls racing on the persistence backend. - // - // On failure, retry with bounded exponential backoff instead of - // silently dropping the write — otherwise a transient IDB/fs error - // leaves the in-memory state permanently diverged from persisted state - // and the user loses queue progress on restart. private saveInTheBackground(): void { if (this.savePending) return; this.savePending = true; queueMicrotask(() => { this.savePending = false; - void this.saveWithRetry(); + this.save(); }); } - - private async saveWithRetry(): Promise { - const maxAttempts = 3; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - await this.save(); - return; - } catch (error) { - if (attempt === maxAttempts) { - this.logger.error( - `Error saving sync state after ${maxAttempts} attempts: ${error}` - ); - return; - } - this.logger.warn( - `Error saving sync state (attempt ${attempt}/${maxAttempts}): ${error}; retrying` - ); - await new Promise((resolve) => - setTimeout(resolve, 50 * attempt) - ); - } - } - } } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 4e51976d..202499f8 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -67,27 +67,6 @@ export class Syncer { this.webSocketManager.onWebSocketStatusChanged.add((isConnected) => { if (isConnected) { this.sendHandshakeMessage(); - } else { - // Don't null the reference synchronously — if the scan is - // still in flight, the next reconnect would spawn a second - // concurrent scan racing on the same queue. Defer the - // clear until the in-flight task actually resolves, so a - // fresh scan can only start once the prior one is done. - const current = this.runningScheduleSyncForOfflineChanges; - if (current === undefined) return; - current - .catch(() => { - /* swallow — internal error already logged */ - }) - .finally(() => { - if ( - this.runningScheduleSyncForOfflineChanges === - current - ) { - this.runningScheduleSyncForOfflineChanges = - undefined; - } - }); } }); this.webSocketManager.onRemoteVaultUpdateReceived.add( @@ -102,20 +81,8 @@ export class Syncer { return this._isFirstSyncComplete; } - public hasPendingOperationsForDocument(relativePath: string): boolean { - return this.queue.hasPendingEventsForPath(relativePath); - } - public syncLocallyCreatedFile(relativePath: RelativePath): void { - this.queue.enqueue({ type: SyncEventType.Create, path: relativePath }); - this.ensureDraining(); - } - - public syncLocallyDeletedFile(relativePath: RelativePath): void { - this.queue.enqueue({ - type: SyncEventType.Delete, - path: relativePath, - }); + this.queue.enqueue({ type: SyncEventType.LocalCreate, path: relativePath }); this.ensureDraining(); } @@ -126,10 +93,78 @@ export class Syncer { oldPath?: RelativePath; relativePath: RelativePath; }): void { - this.queue.enqueue({ type: SyncEventType.SyncLocal, path: relativePath, oldPath }); + this.queue.enqueue({ type: SyncEventType.LocalUpdate, path: relativePath, oldPath }); this.ensureDraining(); } + public syncLocallyDeletedFile(relativePath: RelativePath): void { + this.queue.enqueue({ + type: SyncEventType.LocalDelete, + path: relativePath, + }); + this.ensureDraining(); + } + + + public async syncRemotelyUpdatedFile( + message: WebSocketVaultUpdate + ): Promise { + await this.scheduleSyncForOfflineChanges(); + + for (const remoteVersion of message.documents) { + this.queue.enqueue({ + type: SyncEventType.RemoteUpdate, + remoteVersion + }); + } + + if (message.isInitialSync) { + this._isFirstSyncComplete = true; + } + + this.ensureDraining(); + + } + + // A PathChange notifies us that a document now lives at a new server- + // canonical path. It's delivered to every client (origin included) + // because the create/update HTTP response no longer carries the path, + // so the only way the origin learns about dedupe or first-rename-wins + // is via this event. + // + // Algorithmic assumptions: + // (1) Per-vault broadcast ordering is preserved by the server, so if + // the same write produced a `VaultUpdate` (content change) and a + // `PathChange` (path change), the `VaultUpdate` is handled first + // — that's what lets us skip advancing `parentVersionId` here + // without risking a stuck "already up-to-date" check later. + // (2) On a lag-induced disconnect (`broadcast::error::Lagged`) the + // server disconnects the client for a full resync, so out-of- + // order delivery across a reconnect boundary can't leave us with + // a stale PathChange overwriting a newer one. + public async syncRemotelyChangedPath( + pathChange: WebSocketVaultPathChange + ): Promise { + try { + await this.scheduleSyncForOfflineChanges(); + + this.queue.enqueue({ + type: SyncEventType.RemotePathChange, + pathChange + }); + + await this.scheduleDrain(); + } catch (e) { + if (e instanceof SyncResetError) { + this.logger.info( + "Failed to apply remote path change due to a reset" + ); + return; + } + this.logger.error(`Failed to apply remote path change: ${e}`); + } + } + public async scheduleSyncForOfflineChanges(): Promise { if (this.runningScheduleSyncForOfflineChanges !== undefined) { this.logger.debug("Uploading local changes is already in progress"); @@ -167,114 +202,27 @@ export class Syncer { } } - public async syncRemotelyUpdatedFile( - message: WebSocketVaultUpdate - ): Promise { - try { - await this.scheduleSyncForOfflineChanges(); - - for (const remoteVersion of message.documents) { - this.queue.enqueue({ - type: SyncEventType.SyncRemote, - remoteVersion - }); - } - - if (message.isInitialSync) { - this._isFirstSyncComplete = true; - } - - await this.scheduleDrain(); - } catch (e) { - if (e instanceof SyncResetError) { - this.logger.info( - "Failed to sync remotely updated file due to a reset" - ); - return; - } - this.logger.error(`Failed to sync remotely updated file: ${e}`); - } - } - - // A PathChange notifies us that a document now lives at a new server- - // canonical path. It's delivered to every client (origin included) - // because the create/update HTTP response no longer carries the path, - // so the only way the origin learns about dedupe or first-rename-wins - // is via this event. - // - // Algorithmic assumptions: - // (1) Per-vault broadcast ordering is preserved by the server, so if - // the same write produced a `VaultUpdate` (content change) and a - // `PathChange` (path change), the `VaultUpdate` is handled first - // — that's what lets us skip advancing `parentVersionId` here - // without risking a stuck "already up-to-date" check later. - // (2) On a lag-induced disconnect (`broadcast::error::Lagged`) the - // server disconnects the client for a full resync, so out-of- - // order delivery across a reconnect boundary can't leave us with - // a stale PathChange overwriting a newer one. - public async syncRemotelyChangedPath( - pathChange: WebSocketVaultPathChange - ): Promise { - // Serialize onto the drain chain so this handler can't race against - // an in-flight `processSyncRemote` / `processSyncLocal` etc. that - // captured the old path before our move. - try { - await this.chainOntoDrain(async () => { - const existing = this.queue.getDocumentByDocumentId( - pathChange.documentId - ); - if (existing === undefined) { - throw new Error( - `Received path change for unknown document ${pathChange.documentId}` - ); - } - - const { path: currentPath, record } = existing; - const newPath = pathChange.relativePath; - - if (currentPath !== newPath) { - await this.operations.move(currentPath, newPath); - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.MOVE, - relativePath: newPath, - movedFrom: currentPath - }, - message: "Applied remote path change", - author: pathChange.userId, - timestamp: new Date(pathChange.updatedDate) - }); - } - - // `operations.move` updates the queue's path index, but - // doesn't touch `remoteRelativePath`. Refresh it so offline - // change detection compares against the server's path. - // parentVersionId intentionally stays at its prior value: - // if the write also changed content, the corresponding - // VaultUpdate handles that; advancing it here would make us - // skip fetching content we don't yet have. - this.queue.setDocument(newPath, { - ...record, - remoteRelativePath: newPath - }); - }); - } catch (e) { - if (e instanceof SyncResetError) { - this.logger.info( - "Failed to apply remote path change due to a reset" - ); - return; - } - this.logger.error(`Failed to apply remote path change: ${e}`); - } - } public reset(): void { this._isFirstSyncComplete = false; this.queue.clear(); - this.runningScheduleSyncForOfflineChanges = undefined; + // Don't null the reference synchronously — if the scan is + // still in flight, the next reconnect would spawn a second + // concurrent scan racing on the same queue. Defer the + // clear until the in-flight task actually resolves, so a + // fresh scan can only start once the prior one is done. + const current = this.runningScheduleSyncForOfflineChanges; + if (current !== undefined) { + current.finally(() => { + if ( + this.runningScheduleSyncForOfflineChanges === + current + ) { + this.runningScheduleSyncForOfflineChanges = + undefined; + } + }); + } // Do not set this.draining = undefined — the in-flight drain will // exit naturally (SyncResetError or empty queue) and the promise // chain stays intact, preventing concurrent drain invocations @@ -372,17 +320,20 @@ export class Syncer { try { switch (event.type) { - case SyncEventType.Create: + case SyncEventType.LocalCreate: await this.processCreate(event); break; - case SyncEventType.Delete: + case SyncEventType.LocalDelete: await this.processDelete(event); break; - case SyncEventType.SyncLocal: + case SyncEventType.LocalUpdate: await this.processSyncLocal(event); break; - case SyncEventType.SyncRemote: - await this.processSyncRemote(event); + case SyncEventType.RemoteUpdate: + await this.processSyncRemoteContent(event); + break; + case SyncEventType.RemotePathChange: + await this.processSyncRemotePath(event); break; } } catch (e) { @@ -390,7 +341,7 @@ export class Syncer { this.logger.info( `Skipping sync event '${event.type}' because the file no longer exists` ); - if (event.type === SyncEventType.Create) { + if (event.type === SyncEventType.LocalCreate) { event.resolvers?.promise.catch(() => { }); event.resolvers?.reject(new Error("Create was cancelled")); } @@ -404,7 +355,7 @@ export class Syncer { // `processEvent` ran; if it was a Create, its resolver // promise would otherwise hang forever, blocking any // queued Delete / SyncLocal that `await`s it. - if (event.type === SyncEventType.Create) { + if (event.type === SyncEventType.LocalCreate) { event.resolvers?.promise.catch(() => { /* suppressed */ }); @@ -423,7 +374,7 @@ export class Syncer { private async processCreate( - event: Extract + event: Extract ): Promise { const effectivePath = event.path; const contentBytes = await this.operations.read(effectivePath); @@ -487,7 +438,7 @@ export class Syncer { } private async processDelete( - event: Extract + event: Extract ): Promise { let documentId: DocumentId; if (typeof event.documentId === "string") { @@ -531,7 +482,7 @@ export class Syncer { } private async processSyncLocal( - event: Extract + event: Extract ): Promise { let documentId: DocumentId; if (typeof event.documentId === "string") { @@ -606,8 +557,8 @@ export class Syncer { }); } - private async processSyncRemote( - event: Extract + private async processSyncRemoteContent( + event: Extract ): Promise { const { remoteVersion } = event; const existingDoc = this.queue.getDocumentByDocumentId( @@ -643,6 +594,51 @@ export class Syncer { await this.processRemoteUpdateForNewDocument(remoteVersion); } + private async processSyncRemotePath( + event: Extract + ): Promise { + const { pathChange } = event; + const existing = this.queue.getDocumentByDocumentId( + pathChange.documentId + ); + if (existing === undefined) { + throw new Error( + `Received path change for unknown document ${pathChange.documentId}` + ); + } + + const { path: currentPath, record } = existing; + const newPath = pathChange.relativePath; + + if (currentPath !== newPath) { + await this.operations.move(currentPath, newPath); + + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: { + type: SyncType.MOVE, + relativePath: newPath, + movedFrom: currentPath + }, + message: "Applied remote path change", + author: pathChange.userId, + timestamp: new Date(pathChange.updatedDate) + }); + } + + // `operations.move` updates the queue's path index, but doesn't + // touch `remoteRelativePath`. Refresh it so offline change + // detection compares against the server's path. parentVersionId + // intentionally stays at its prior value: if the write also + // changed content, the corresponding VaultUpdate handles that; + // advancing it here would make us skip fetching content we don't + // yet have. + this.queue.setDocument(newPath, { + ...record, + remoteRelativePath: newPath + }); + } + private async processRemoteUpdateForExistingDocument( currentPath: RelativePath, record: DocumentRecord, @@ -793,14 +789,14 @@ export class Syncer { details: targetPath !== currentPath ? { - type: SyncType.MOVE, - relativePath: targetPath, - movedFrom: currentPath - } + type: SyncType.MOVE, + relativePath: targetPath, + movedFrom: currentPath + } : { - type: SyncType.UPDATE, - relativePath: targetPath - }, + type: SyncType.UPDATE, + relativePath: targetPath + }, message: "Successfully downloaded remotely updated file from the server", author: fullVersion.userId, @@ -1063,7 +1059,7 @@ export class Syncer { // `resolvers` promise can be fulfilled (or rejected, on a deleted // response). Dependent SyncLocal/Delete events are chained through // that promise and would otherwise `await` forever. - createEvent?: Extract; + createEvent?: Extract; }): Promise { if (response.isDeleted) { // A Create that the server returned as already-deleted means @@ -1222,7 +1218,7 @@ export class Syncer { } private notifyRemainingOperationsChanged(): void { - const currentCount = this.queue.size; + const currentCount = this.queue.pendingUpdateCount; if (this.previousRemainingOperationsCount !== currentCount) { this.previousRemainingOperationsCount = currentCount; this.onRemainingOperationsCountChanged.trigger(currentCount); diff --git a/frontend/sync-client/src/sync-operations/types.ts b/frontend/sync-client/src/sync-operations/types.ts index 4db503c4..22b82b3e 100644 --- a/frontend/sync-client/src/sync-operations/types.ts +++ b/frontend/sync-client/src/sync-operations/types.ts @@ -1,4 +1,5 @@ import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; +import type { WebSocketVaultPathChange } from "../services/types/WebSocketVaultPathChange"; export type VaultUpdateId = number; export type DocumentId = string; @@ -21,36 +22,43 @@ export interface StoredSyncState { } export enum SyncEventType { - Create = "create", - SyncLocal = "sync-local", - Delete = "delete", - SyncRemote = "sync-remote", + LocalCreate = "local-create", + LocalUpdate = "local-update", // includes both content and path changes + LocalDelete = "local-delete", + RemoteUpdate = "remote-update", + RemotePathChange = "remote-path-change", } export type FileSyncEvent = - | { type: SyncEventType.Create; path: RelativePath } - | { type: SyncEventType.SyncLocal; path: RelativePath; oldPath?: RelativePath } - | { type: SyncEventType.Delete; path: RelativePath } - | { type: SyncEventType.SyncRemote; remoteVersion: DocumentVersionWithoutContent }; + | { type: SyncEventType.LocalCreate; path: RelativePath } + | { type: SyncEventType.LocalUpdate; path: RelativePath; oldPath?: RelativePath } + | { type: SyncEventType.LocalDelete; path: RelativePath } + | { type: SyncEventType.RemoteUpdate; remoteVersion: DocumentVersionWithoutContent } + | { type: SyncEventType.RemotePathChange; pathChange: WebSocketVaultPathChange }; export type SyncEvent = | { - type: SyncEventType.Create; + type: SyncEventType.LocalCreate; path: RelativePath; // current path on disk - originalPath: RelativePath; // original path on disk when the event was created + originalPath: RelativePath; // original path on disk when the event was queued resolvers?: PromiseWithResolvers } | { - type: SyncEventType.SyncLocal; + type: SyncEventType.LocalUpdate; documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed path: RelativePath; // current path on disk - originalPath: RelativePath; // original path on disk when the event was created + originalPath: RelativePath; // original path on disk when the event was queued + // no need to store the old path in case of a rename; the server will figure it out from the parent's path } | { - type: SyncEventType.Delete; + type: SyncEventType.LocalDelete; documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed } | { - type: SyncEventType.SyncRemote; + type: SyncEventType.RemoteUpdate; remoteVersion: DocumentVersionWithoutContent; + } + | { + type: SyncEventType.RemotePathChange; + pathChange: WebSocketVaultPathChange; }; diff --git a/sync-server/src/app_state/websocket/models.rs b/sync-server/src/app_state/websocket/models.rs index 9ed52400..73e81f26 100644 --- a/sync-server/src/app_state/websocket/models.rs +++ b/sync-server/src/app_state/websocket/models.rs @@ -58,6 +58,7 @@ pub struct CursorPositionFromServer { pub clients: Vec, } +// Clients only get notified of other clients' updates through WebSocketVaultUpdate. #[derive(TS, Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct WebSocketVaultUpdate { @@ -65,6 +66,8 @@ pub struct WebSocketVaultUpdate { pub is_initial_sync: bool, } +// Clients get notified of both their own and other clients' path changes through WebSocketVaultPathChange. +// This is becuase we must absolutely order path updates as they may all depend on all previous updates. #[derive(TS, Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct WebSocketVaultPathChange { From 17a1f4d060e791a6f7f64d77c59e6670d4fc0454 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 24 Apr 2026 21:33:00 +0100 Subject: [PATCH 10/52] no remote path chacnge --- .../src/lib/types/WebSocketServerMessage.ts | 3 +- .../src/lib/types/WebSocketVaultPathChange.ts | 3 - .../src/lib/types/WebSocketVaultUpdate.ts | 2 +- .../services/types/WebSocketServerMessage.ts | 3 +- .../types/WebSocketVaultPathChange.ts | 3 - .../services/types/WebSocketVaultUpdate.ts | 2 +- .../src/services/websocket-manager.ts | 11 -- .../src/sync-operations/sync-event-queue.ts | 43 ++---- .../sync-client/src/sync-operations/syncer.ts | 146 ++++-------------- .../sync-client/src/sync-operations/types.ts | 11 +- sync-server/src/app_state/database.rs | 70 ++------- sync-server/src/app_state/websocket/models.rs | 25 ++- sync-server/src/server/create_document.rs | 19 +-- sync-server/src/server/delete_document.rs | 17 +- sync-server/src/server/update_document.rs | 20 +-- sync-server/src/server/websocket.rs | 29 ++-- 16 files changed, 93 insertions(+), 314 deletions(-) delete mode 100644 frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts delete mode 100644 frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts diff --git a/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts b/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts index 09bd3e86..45e37358 100644 --- a/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts +++ b/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts @@ -1,6 +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 { WebSocketVaultPathChange } from "./WebSocketVaultPathChange"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; -export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "pathChange" } & WebSocketVaultPathChange | { "type": "cursorPositions" } & CursorPositionFromServer; +export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer; diff --git a/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts b/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts deleted file mode 100644 index 6ae24f75..00000000 --- a/frontend/history-ui/src/lib/types/WebSocketVaultPathChange.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type WebSocketVaultPathChange = { vaultUpdateId: number, documentId: string, relativePath: string, }; diff --git a/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts b/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts index b627ac3c..fc10827f 100644 --- a/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts +++ b/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts @@ -1,4 +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, isInitialSync: boolean, }; +export type WebSocketVaultUpdate = { document: DocumentVersionWithoutContent, }; diff --git a/frontend/sync-client/src/services/types/WebSocketServerMessage.ts b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts index 09bd3e86..45e37358 100644 --- a/frontend/sync-client/src/services/types/WebSocketServerMessage.ts +++ b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts @@ -1,6 +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 { WebSocketVaultPathChange } from "./WebSocketVaultPathChange"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; -export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "pathChange" } & WebSocketVaultPathChange | { "type": "cursorPositions" } & CursorPositionFromServer; +export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer; diff --git a/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts b/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts deleted file mode 100644 index f59ca5a5..00000000 --- a/frontend/sync-client/src/services/types/WebSocketVaultPathChange.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export interface WebSocketVaultPathChange { vaultUpdateId: number, documentId: string, relativePath: string, } diff --git a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts index 39e03b6f..5e7df8a5 100644 --- a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts +++ b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts @@ -1,4 +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 interface WebSocketVaultUpdate { documents: DocumentVersionWithoutContent[], isInitialSync: boolean, } +export interface WebSocketVaultUpdate { document: DocumentVersionWithoutContent, } diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index 6c938dc7..970defb3 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 type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate"; -import type { WebSocketVaultPathChange } from "./types/WebSocketVaultPathChange"; import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS, WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS @@ -23,10 +22,6 @@ export class WebSocketManager { (update: WebSocketVaultUpdate) => Promise >(); - public readonly onRemotePathChangeReceived = new EventListeners< - (pathChange: WebSocketVaultPathChange) => Promise - >(); - public readonly onRemoteCursorsUpdateReceived = new EventListeners< (cursors: ClientCursors[]) => Promise >(); @@ -295,12 +290,6 @@ export class WebSocketManager { case "vaultUpdate": await this.onRemoteVaultUpdateReceived.triggerAsync(message); return; - case "pathChange": - this.logger.debug( - `Received path change for document ${message.documentId} → ${message.relativePath}` - ); - await this.onRemotePathChangeReceived.triggerAsync(message); - return; case "cursorPositions": this.logger.debug( `Received cursor positions for ${JSON.stringify(message.clients)}` diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index 8a19009a..d6859ead 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -44,7 +44,7 @@ export class SyncEventQueue { private savePending = false; - private lastSeenUpdateId: VaultUpdateId; + private readonly lastSeenUpdateId: VaultUpdateId; public constructor( private readonly settings: Settings, @@ -250,9 +250,7 @@ export class SyncEventQueue { e.documentId === docId) || (e.type === SyncEventType.RemoteUpdate && // we care about the local path not the remote - this.getDocumentByDocumentId(e.remoteVersion.documentId)?.path === path) || - (e.type === SyncEventType.RemotePathChange && - this.getDocumentByDocumentId(e.pathChange.documentId)?.path === path) + this.getDocumentByDocumentId(e.remoteVersion.documentId)?.path === path) ); } @@ -280,10 +278,7 @@ export class SyncEventQueue { } public enqueue(input: FileSyncEvent): void { - if ( - input.type === SyncEventType.RemoteUpdate || - input.type === SyncEventType.RemotePathChange - ) { + if (input.type === SyncEventType.RemoteUpdate) { this.events.push(input); return; } @@ -414,31 +409,15 @@ export class SyncEventQueue { return result; } - // Coalesce multiple events of the same remote kind for the same - // documentId to the last one. Kinds are coalesced independently so - // that an interleaved content+path stream (e.g. VaultUpdate → - // PathChange) still preserves the VaultUpdate-before-PathChange - // ordering invariant the syncer relies on. - if (first.type === SyncEventType.RemoteUpdate) { - const { documentId } = first.remoteVersion; - const matching = this.events.filter( - (e) => - e.type === SyncEventType.RemoteUpdate && - e.remoteVersion.documentId === documentId - ); - const result = matching[matching.length - 1]; - for (const item of matching) { - removeFromArray(this.events, item); - } - return result; - } - - // SyncRemotePath - const { documentId } = first.pathChange; + // Coalesce multiple RemoteUpdate events for the same documentId + // down to the last one — the `.next` walk already short-circuits + // on obsolete versions via `parentVersionId` checks, but compacting + // here keeps the queue bounded under burst remote activity. + const { documentId } = first.remoteVersion; const matching = this.events.filter( (e) => - e.type === SyncEventType.RemotePathChange && - e.pathChange.documentId === documentId + e.type === SyncEventType.RemoteUpdate && + e.remoteVersion.documentId === documentId ); const result = matching[matching.length - 1]; for (const item of matching) { @@ -463,8 +442,6 @@ export class SyncEventQueue { e.documentId === documentId) || (e.type === SyncEventType.RemoteUpdate && e.remoteVersion.documentId === documentId) || - (e.type === SyncEventType.RemotePathChange && - e.pathChange.documentId === documentId) || (e.type === SyncEventType.LocalDelete && e.documentId === documentId) ) { diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 202499f8..9d009c3f 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -14,7 +14,6 @@ import { scheduleOfflineChanges } from "./offline-change-detector"; import { SyncResetError } from "../errors/sync-reset-error"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate"; -import type { WebSocketVaultPathChange } from "../services/types/WebSocketVaultPathChange"; import type { WebSocketManager } from "../services/websocket-manager"; import type { WebSocketClientMessage } from "../services/types/WebSocketClientMessage"; import { EventListeners } from "../utils/data-structures/event-listeners"; @@ -67,14 +66,19 @@ export class Syncer { this.webSocketManager.onWebSocketStatusChanged.add((isConnected) => { if (isConnected) { this.sendHandshakeMessage(); + // The server no longer carries an `is_initial_sync` + // terminator: it streams missed versions as individual + // VaultUpdates and then behaves like a live subscription. + // Mark first-sync as complete once we've observed the + // transition to "connected" — per-path sync status still + // relies on `hasPendingEventsForPath`, which correctly + // shows SYNCING while catch-up events are in flight. + this._isFirstSyncComplete = true; } }); this.webSocketManager.onRemoteVaultUpdateReceived.add( this.syncRemotelyUpdatedFile.bind(this) ); - this.webSocketManager.onRemotePathChangeReceived.add( - this.syncRemotelyChangedPath.bind(this) - ); } public get isFirstSyncComplete(): boolean { @@ -106,63 +110,22 @@ export class Syncer { } + // Handler for every `WebSocketVaultUpdate` the server emits. The + // server filters out messages authored by this device, so every + // update here comes from a peer (or is part of the catch-up stream + // the server replays on connect for versions we missed while + // offline). public async syncRemotelyUpdatedFile( message: WebSocketVaultUpdate ): Promise { await this.scheduleSyncForOfflineChanges(); - for (const remoteVersion of message.documents) { - this.queue.enqueue({ - type: SyncEventType.RemoteUpdate, - remoteVersion - }); - } - - if (message.isInitialSync) { - this._isFirstSyncComplete = true; - } + this.queue.enqueue({ + type: SyncEventType.RemoteUpdate, + remoteVersion: message.document + }); this.ensureDraining(); - - } - - // A PathChange notifies us that a document now lives at a new server- - // canonical path. It's delivered to every client (origin included) - // because the create/update HTTP response no longer carries the path, - // so the only way the origin learns about dedupe or first-rename-wins - // is via this event. - // - // Algorithmic assumptions: - // (1) Per-vault broadcast ordering is preserved by the server, so if - // the same write produced a `VaultUpdate` (content change) and a - // `PathChange` (path change), the `VaultUpdate` is handled first - // — that's what lets us skip advancing `parentVersionId` here - // without risking a stuck "already up-to-date" check later. - // (2) On a lag-induced disconnect (`broadcast::error::Lagged`) the - // server disconnects the client for a full resync, so out-of- - // order delivery across a reconnect boundary can't leave us with - // a stale PathChange overwriting a newer one. - public async syncRemotelyChangedPath( - pathChange: WebSocketVaultPathChange - ): Promise { - try { - await this.scheduleSyncForOfflineChanges(); - - this.queue.enqueue({ - type: SyncEventType.RemotePathChange, - pathChange - }); - - await this.scheduleDrain(); - } catch (e) { - if (e instanceof SyncResetError) { - this.logger.info( - "Failed to apply remote path change due to a reset" - ); - return; - } - this.logger.error(`Failed to apply remote path change: ${e}`); - } } public async scheduleSyncForOfflineChanges(): Promise { @@ -332,9 +295,6 @@ export class Syncer { case SyncEventType.RemoteUpdate: await this.processSyncRemoteContent(event); break; - case SyncEventType.RemotePathChange: - await this.processSyncRemotePath(event); - break; } } catch (e) { if (e instanceof FileNotFoundError) { @@ -594,51 +554,6 @@ export class Syncer { await this.processRemoteUpdateForNewDocument(remoteVersion); } - private async processSyncRemotePath( - event: Extract - ): Promise { - const { pathChange } = event; - const existing = this.queue.getDocumentByDocumentId( - pathChange.documentId - ); - if (existing === undefined) { - throw new Error( - `Received path change for unknown document ${pathChange.documentId}` - ); - } - - const { path: currentPath, record } = existing; - const newPath = pathChange.relativePath; - - if (currentPath !== newPath) { - await this.operations.move(currentPath, newPath); - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.MOVE, - relativePath: newPath, - movedFrom: currentPath - }, - message: "Applied remote path change", - author: pathChange.userId, - timestamp: new Date(pathChange.updatedDate) - }); - } - - // `operations.move` updates the queue's path index, but doesn't - // touch `remoteRelativePath`. Refresh it so offline change - // detection compares against the server's path. parentVersionId - // intentionally stays at its prior value: if the write also - // changed content, the corresponding VaultUpdate handles that; - // advancing it here would make us skip fetching content we don't - // yet have. - this.queue.setDocument(newPath, { - ...record, - remoteRelativePath: newPath - }); - } - private async processRemoteUpdateForExistingDocument( currentPath: RelativePath, record: DocumentRecord, @@ -734,14 +649,14 @@ export class Syncer { // Path reconciliation fallback for the reconnect case. // - // In steady-state streaming, server-initiated renames arrive as - // dedicated `PathChange` WebSocket events and are handled by - // `syncRemotelyChangedPath`. But the reconnect catch-up path - // (`get_unseen_documents` → `VaultUpdate(is_initial_sync=…)`) - // replays *versions* from the DB — `PathChange` is emission- - // only and not replayed. Without this branch, a pure rename - // that happened while we were disconnected would leave our - // local file stuck at its old path forever. + // In steady-state streaming, server-initiated renames arrive + // as `VaultUpdate` events with `originatesFromSelf=true` for + // the author and drive `processSyncRemotePath`. The reconnect + // catch-up (`get_unseen_documents` → `is_initial_sync=true`) + // replays versions authored by any device with + // `originatesFromSelf=false`, so those take the full remote- + // sync branch and we need this in-branch path reconciliation + // to avoid leaving the local file stuck at its old path. // // Only apply the server's path when the record's // `remoteRelativePath` still matches `currentPath` — that means @@ -1107,8 +1022,8 @@ export class Syncer { } } // Only delete on disk if the record at `path` is still the one - // we expected — if a PathChange moved another doc here, we - // shouldn't delete its file. + // we expected — if a self-origin path-change moved another doc + // here, we shouldn't delete its file. const finalRecord = this.queue.getSettledDocumentByPath(path); if ( finalRecord === undefined || @@ -1121,9 +1036,10 @@ export class Syncer { } // The response carries content only — path reconciliation is the - // sole responsibility of the `PathChange` WebSocket event, which - // fires independently for renames/dedupes. We therefore always - // record the current local `path` here; an in-flight `PathChange` + // sole responsibility of the self-origin `VaultUpdate` echo (the + // `originatesFromSelf=true` branch of `syncRemoteVaultUpdate`), + // which fires independently for renames/dedupes. We therefore + // always record the current local `path` here; an in-flight echo // will move the file and fix `remoteRelativePath` if the server // placed the document somewhere else. const existingRecord = this.queue.getSettledDocumentByPath(path); diff --git a/frontend/sync-client/src/sync-operations/types.ts b/frontend/sync-client/src/sync-operations/types.ts index 22b82b3e..4a201ad9 100644 --- a/frontend/sync-client/src/sync-operations/types.ts +++ b/frontend/sync-client/src/sync-operations/types.ts @@ -1,5 +1,4 @@ import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; -import type { WebSocketVaultPathChange } from "../services/types/WebSocketVaultPathChange"; export type VaultUpdateId = number; export type DocumentId = string; @@ -25,16 +24,14 @@ export enum SyncEventType { LocalCreate = "local-create", LocalUpdate = "local-update", // includes both content and path changes LocalDelete = "local-delete", - RemoteUpdate = "remote-update", - RemotePathChange = "remote-path-change", + RemoteUpdate = "remote-update", // includes every type of update coming from the server } export type FileSyncEvent = | { type: SyncEventType.LocalCreate; path: RelativePath } | { type: SyncEventType.LocalUpdate; path: RelativePath; oldPath?: RelativePath } | { type: SyncEventType.LocalDelete; path: RelativePath } - | { type: SyncEventType.RemoteUpdate; remoteVersion: DocumentVersionWithoutContent } - | { type: SyncEventType.RemotePathChange; pathChange: WebSocketVaultPathChange }; + | { type: SyncEventType.RemoteUpdate; remoteVersion: DocumentVersionWithoutContent }; export type SyncEvent = | { @@ -57,8 +54,4 @@ export type SyncEvent = | { type: SyncEventType.RemoteUpdate; remoteVersion: DocumentVersionWithoutContent; - } - | { - type: SyncEventType.RemotePathChange; - pathChange: WebSocketVaultPathChange; }; diff --git a/sync-server/src/app_state/database.rs b/sync-server/src/app_state/database.rs index c9ea9746..8e505083 100644 --- a/sync-server/src/app_state/database.rs +++ b/sync-server/src/app_state/database.rs @@ -20,24 +20,6 @@ pub mod models; #[error("Database is busy")] pub struct WriteBusyError; -/// Tells [`Database::insert_document_version`] which WebSocket events the -/// just-committed version should produce. The caller is the only party -/// with enough context to decide this (the DB layer has no access to -/// "what the client sent" or "what the prior version looked like"). -#[derive(Debug, Clone, Copy, Default)] -pub struct InsertBroadcast { - /// Emit a `VaultUpdate` (filtered from the origin device). Set when - /// the stored bytes differ from the prior version's bytes — i.e. - /// peers need to pull new content. - pub content_changed: bool, - - /// Emit a `PathChange` (delivered to every client, origin included). - /// Set when the stored path differs from the prior stored path *or* - /// from the path the origin client sent — i.e. someone needs to - /// reconcile a dedupe, rename, or first-rename-wins outcome. - pub path_changed: bool, -} - use sqlx::{ Pool, Sqlite, pool::PoolConnection, sqlite::SqliteConnection, sqlite::SqlitePoolOptions, }; @@ -47,10 +29,7 @@ use uuid::fmt::Hyphenated; use super::websocket::{ broadcasts::Broadcasts, - models::{ - WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultPathChange, - WebSocketVaultUpdate, - }, + models::{WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultUpdate}, }; use crate::config::database_config::DatabaseConfig; use crate::consts::IDLE_POOL_TIMEOUT; @@ -693,7 +672,6 @@ impl Database { vault_id: &VaultId, version: &StoredDocumentVersion, mut transaction: WriteTransaction, - broadcast: InsertBroadcast, ) -> Result<()> { let document_id = version.document_id.as_hyphenated(); let query = sqlx::query!( @@ -739,39 +717,19 @@ impl Database { .await .context("Failed to commit transaction")?; - if broadcast.content_changed { - // Content events are filtered out for the origin device — the - // origin already has the content (or learns about the merge - // via the HTTP response). - self.broadcasts.send_document_update( - vault_id.clone(), - WebSocketServerMessageWithOrigin::with_origin( - version.device_id.clone(), - WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { - documents: vec![version.clone().into()], - is_initial_sync: false, - }), - ), - ); - } - - if broadcast.path_changed { - // Path change events intentionally carry no origin so *every* - // connected client (including the one that made the write) - // receives them. The create/update HTTP response no longer - // carries `relative_path`, so the origin device relies on this - // event to learn the server-canonical path. - self.broadcasts.send_document_update( - vault_id.clone(), - WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::PathChange( - WebSocketVaultPathChange { - vault_update_id: version.vault_update_id, - document_id: version.document_id, - relative_path: version.relative_path.clone(), - }, - )), - ); - } + // The broadcast is delivered to every connected client except the + // author — the send task filters on `origin_device_id` (see + // `websocket.rs`). The origin already has authoritative state + // from the HTTP response that triggered this write. + self.broadcasts.send_document_update( + vault_id.clone(), + WebSocketServerMessageWithOrigin::with_origin( + version.device_id.clone(), + WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { + document: version.clone().into(), + }), + ), + ); Ok(()) } diff --git a/sync-server/src/app_state/websocket/models.rs b/sync-server/src/app_state/websocket/models.rs index 73e81f26..983c0dad 100644 --- a/sync-server/src/app_state/websocket/models.rs +++ b/sync-server/src/app_state/websocket/models.rs @@ -58,23 +58,15 @@ pub struct CursorPositionFromServer { pub clients: Vec, } -// Clients only get notified of other clients' updates through WebSocketVaultUpdate. +// One committed version, broadcast to every connected client *except* +// the device that authored it — that device already has the new state +// via its HTTP response. The server also emits these one-at-a-time to +// catch up a freshly-connected client on versions committed while it +// was offline, in ascending `vault_update_id` order. #[derive(TS, Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct WebSocketVaultUpdate { - pub documents: Vec, - pub is_initial_sync: bool, -} - -// Clients get notified of both their own and other clients' path changes through WebSocketVaultPathChange. -// This is becuase we must absolutely order path updates as they may all depend on all previous updates. -#[derive(TS, Serialize, Clone, Debug)] -#[serde(rename_all = "camelCase")] -pub struct WebSocketVaultPathChange { - #[ts(type = "number")] - pub vault_update_id: VaultUpdateId, - pub document_id: DocumentId, - pub relative_path: String, + pub document: DocumentVersionWithoutContent, } #[derive(TS, Deserialize, Clone, Debug)] @@ -90,10 +82,13 @@ pub enum WebSocketClientMessage { #[ts(export)] pub enum WebSocketServerMessage { VaultUpdate(WebSocketVaultUpdate), - PathChange(WebSocketVaultPathChange), CursorPositions(CursorPositionFromServer), } +/// Broadcast envelope carrying the message plus the device that produced +/// it. The per-recipient send task compares `origin_device_id` against +/// its own device id to fill in `originates_from_self` before the message +/// is serialized on the wire. #[derive(Clone, Debug)] pub struct WebSocketServerMessageWithOrigin { pub origin_device_id: Option, diff --git a/sync-server/src/server/create_document.rs b/sync-server/src/server/create_document.rs index 64c3c5fe..84703139 100644 --- a/sync-server/src/server/create_document.rs +++ b/sync-server/src/server/create_document.rs @@ -11,10 +11,7 @@ use super::{device_id_header::DeviceIdHeader, requests::CreateDocumentVersion}; use crate::{ app_state::{ AppState, - database::{ - InsertBroadcast, - models::{StoredDocumentVersion, VaultId}, - }, + database::models::{StoredDocumentVersion, VaultId}, }, config::user_config::User, errors::{SyncServerError, client_error, server_error, write_transaction_error}, @@ -128,8 +125,6 @@ pub async fn create_document( ); } - let path_changed = deduped_path != sanitized_relative_path; - let new_vault_update_id = last_update_id + 1; let new_version = StoredDocumentVersion { vault_update_id: new_vault_update_id, @@ -146,17 +141,7 @@ pub async fn create_document( state .database - .insert_document_version( - &vault_id, - &new_version, - transaction, - InsertBroadcast { - // A brand-new document is always a content change for peers. - content_changed: true, - // Origin needs to know if the server deduped its requested path. - path_changed, - }, - ) + .insert_document_version(&vault_id, &new_version, transaction) .await .map_err(server_error)?; diff --git a/sync-server/src/server/delete_document.rs b/sync-server/src/server/delete_document.rs index aeec13d3..76f92b71 100644 --- a/sync-server/src/server/delete_document.rs +++ b/sync-server/src/server/delete_document.rs @@ -11,10 +11,7 @@ use super::device_id_header::DeviceIdHeader; use crate::{ app_state::{ AppState, - database::{ - InsertBroadcast, - models::{DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId}, - }, + database::models::{DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId}, }, config::user_config::User, errors::{SyncServerError, not_found_error, server_error, write_transaction_error}, @@ -101,17 +98,7 @@ pub async fn delete_document( state .database - .insert_document_version( - &vault_id, - &new_version, - transaction, - InsertBroadcast { - // Deletion is a content change peers must learn about. - content_changed: true, - // Delete never renames. - path_changed: false, - }, - ) + .insert_document_version(&vault_id, &new_version, transaction) .await .map_err(server_error)?; diff --git a/sync-server/src/server/update_document.rs b/sync-server/src/server/update_document.rs index 1963310a..8b9c3bf5 100644 --- a/sync-server/src/server/update_document.rs +++ b/sync-server/src/server/update_document.rs @@ -17,7 +17,7 @@ use crate::{ app_state::{ AppState, database::{ - InsertBroadcast, WriteTransaction, + WriteTransaction, models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId}, }, }, @@ -292,14 +292,6 @@ pub async fn update_document( latest_version.relative_path.clone() }; - let content_changed = merged_content != latest_version.content; - // Stored path differs from either the prior stored path (peers need - // to learn about the rename) or from the path the origin sent - // (origin needs to learn if its rename was deduped or rejected by - // first-rename-wins). - let path_changed = new_relative_path != latest_version.relative_path - || new_relative_path != sanitized_relative_path; - let new_version = StoredDocumentVersion { document_id, vault_update_id: last_update_id + 1, @@ -315,15 +307,7 @@ pub async fn update_document( state .database - .insert_document_version( - &vault_id, - &new_version, - transaction, - InsertBroadcast { - content_changed, - path_changed, - }, - ) + .insert_document_version(&vault_id, &new_version, transaction) .await .map_err(server_error)?; diff --git a/sync-server/src/server/websocket.rs b/sync-server/src/server/websocket.rs index 4540539a..46d67533 100644 --- a/sync-server/src/server/websocket.rs +++ b/sync-server/src/server/websocket.rs @@ -134,19 +134,21 @@ async fn websocket( } }; - send_update_over_websocket( - &WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { - documents: get_unseen_documents( - &state, - &vault_id, - authed_handshake.handshake.last_seen_vault_update_id, - ) - .await?, - is_initial_sync: true, - }), - &mut sender, + // Catch-up on versions committed while this client was offline, + // streamed one-at-a-time in ascending `vault_update_id` order + let unseen_documents = get_unseen_documents( + &state, + &vault_id, + authed_handshake.handshake.last_seen_vault_update_id, ) .await?; + for document in unseen_documents { + send_update_over_websocket( + &WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { document }), + &mut sender, + ) + .await?; + } send_update_over_websocket( &WebSocketServerMessage::CursorPositions(CursorPositionFromServer { @@ -161,6 +163,8 @@ async fn websocket( loop { match broadcast_receiver.recv().await { Ok(update) => { + // Drop messages this device authored because the HTTP + // response already carried authoritative state back. if Some(&device_id) == update.origin_device_id.as_ref() { continue; } @@ -174,8 +178,7 @@ async fn websocket( .filter(|client| client.device_id != device_id) .collect(), }), - WebSocketServerMessage::VaultUpdate(_) - | WebSocketServerMessage::PathChange(_) => update.message, + WebSocketServerMessage::VaultUpdate(_) => update.message, }; send_update_over_websocket(&message, &mut sender).await?; From c9cf3239dbde09507120238778b18243e0f2c2d4 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 24 Apr 2026 21:59:00 +0100 Subject: [PATCH 11/52] .. --- .../src/components/DocumentDetail.svelte | 23 +- frontend/history-ui/src/lib/api.ts | 51 ++- frontend/history-ui/src/lib/types/index.ts | 1 + .../src/sync-operations/conflict-path.ts | 12 +- .../sync-operations/sync-event-queue.test.ts | 10 +- .../src/sync-operations/sync-event-queue.ts | 384 ++++++------------ .../sync-client/src/sync-operations/syncer.ts | 35 +- .../sync-client/src/sync-operations/types.ts | 2 +- sync-server/src/server.rs | 5 - .../src/server/restore_document_version.rs | 186 --------- 10 files changed, 200 insertions(+), 509 deletions(-) delete mode 100644 sync-server/src/server/restore_document_version.rs diff --git a/frontend/history-ui/src/components/DocumentDetail.svelte b/frontend/history-ui/src/components/DocumentDetail.svelte index 556a5e8d..dbdcc03f 100644 --- a/frontend/history-ui/src/components/DocumentDetail.svelte +++ b/frontend/history-ui/src/components/DocumentDetail.svelte @@ -152,13 +152,32 @@ async function executeRestore() { const api = auth.api; - if (!api || !restoreTarget) return; + if (!api || !restoreTarget || !latest) return; restoring = true; try { - await api.restoreVersion( + // Restore = re-submit the target version's bytes at its path + // as if it were a fresh edit. `update_document` short-circuits + // on `is_deleted`, so resurrecting a deleted doc has to go + // through `create_document`; a live doc takes the normal + // update path with the current latest as its parent. + const bytes = await api.fetchDocumentVersionContent( documentId, restoreTarget.vaultUpdateId ); + if (latest.isDeleted) { + await api.createDocument( + latest.vaultUpdateId, + restoreTarget.relativePath, + bytes + ); + } else { + await api.updateBinaryDocument( + documentId, + latest.vaultUpdateId, + restoreTarget.relativePath, + bytes + ); + } toasts.add( `Restored to version #${restoreTarget.vaultUpdateId}`, "success" diff --git a/frontend/history-ui/src/lib/api.ts b/frontend/history-ui/src/lib/api.ts index d80b5eb1..a69a575e 100644 --- a/frontend/history-ui/src/lib/api.ts +++ b/frontend/history-ui/src/lib/api.ts @@ -1,4 +1,5 @@ import type { + DocumentUpdateResponse, DocumentVersion, DocumentVersionWithoutContent, FetchLatestDocumentsResponse, @@ -108,19 +109,47 @@ export class ApiClient { ); } - async restoreVersion( + /** + * Upload a new version of an existing (non-deleted) document. The + * server treats this like any other edit — server-side merging, + * path dedupe, and broadcast still apply. Used by the UI to restore + * an old version by re-submitting its bytes on top of the latest. + */ + async updateBinaryDocument( documentId: string, - vaultUpdateId: number - ): Promise { + parentVersionId: number, + relativePath: string, + content: ArrayBuffer + ): Promise { + const form = new FormData(); + form.append("parent_version_id", String(parentVersionId)); + form.append("relative_path", relativePath); + form.append("content", new Blob([content])); return this.fetchJson( - `${this.baseUrl}/documents/${documentId}/restore`, - { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ vaultUpdateId }) - } + `${this.baseUrl}/documents/${documentId}/binary`, + { method: "PUT", body: form } ); } + + /** + * Create a new document. Used by the UI to restore a deleted + * document: `update_document` short-circuits on `is_deleted`, so + * resurrection has to go through `create_document` — which detects + * an existing doc at the same path, merges or dedupes as needed, + * and returns the resulting version. + */ + async createDocument( + lastSeenVaultUpdateId: number, + relativePath: string, + content: ArrayBuffer + ): Promise { + const form = new FormData(); + form.append("last_seen_vault_update_id", String(lastSeenVaultUpdateId)); + form.append("relative_path", relativePath); + form.append("content", new Blob([content])); + return this.fetchJson(`${this.baseUrl}/documents`, { + method: "POST", + body: form + }); + } } diff --git a/frontend/history-ui/src/lib/types/index.ts b/frontend/history-ui/src/lib/types/index.ts index ad1b4d41..526e01cd 100644 --- a/frontend/history-ui/src/lib/types/index.ts +++ b/frontend/history-ui/src/lib/types/index.ts @@ -1,5 +1,6 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; +export type { DocumentUpdateResponse } from "./DocumentUpdateResponse"; export type { DocumentVersion } from "./DocumentVersion"; export type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; export type { FetchLatestDocumentsResponse } from "./FetchLatestDocumentsResponse"; diff --git a/frontend/sync-client/src/sync-operations/conflict-path.ts b/frontend/sync-client/src/sync-operations/conflict-path.ts index 9e107b9a..bd1d7c0b 100644 --- a/frontend/sync-client/src/sync-operations/conflict-path.ts +++ b/frontend/sync-client/src/sync-operations/conflict-path.ts @@ -3,9 +3,12 @@ import type { RelativePath } from "./types"; // Local-only files displaced by `FileOperations.ensureClearPath` are named // `conflict--`. The UUID is a full RFC-4122 v4 value so // a user-authored filename that happens to start with `conflict-` doesn't -// get misclassified. -const CONFLICT_UUID_REGEX = - /^conflict-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-/u; +// get misclassified. The leading `(?:^|\/)` and trailing `[^/]*$` anchor the +// match to the final path segment so intermediate directories named after +// old conflict files (if a user renames one into a directory) don't ignore +// everything beneath them. +export const CONFLICT_PATH_REGEX = + /(?:^|\/)conflict-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-[^/]*$/u; // Safe segment length for common filesystems (ext4 / NTFS / APFS all cap // at 255 bytes). `conflict-<36-char-uuid>-` adds 46 bytes; reserve a few @@ -61,6 +64,5 @@ function truncateFileNameToByteLimit( * strictly local and must stay invisible to the server. */ export function isConflictPath(path: RelativePath): boolean { - const fileName = path.substring(path.lastIndexOf("/") + 1); - return CONFLICT_UUID_REGEX.test(fileName); + return CONFLICT_PATH_REGEX.test(path); } diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts index a33aa258..db6c9a19 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts @@ -278,7 +278,7 @@ describe("SyncEventQueue", () => { const queue = createQueue(); queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - const promise = queue.getCreatePromise("a.md"); + const promise = queue.getLatestCreatePromise("a.md"); assert.ok(promise !== undefined); // The syncer resolves via event.resolvers after dequeuing @@ -294,7 +294,7 @@ describe("SyncEventQueue", () => { const queue = createQueue(); queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - const promise = queue.getCreatePromise("a.md"); + const promise = queue.getLatestCreatePromise("a.md"); assert.ok(promise !== undefined); const event = await queue.next(); @@ -311,8 +311,8 @@ describe("SyncEventQueue", () => { queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); - const promiseA = queue.getCreatePromise("a.md"); - const promiseB = queue.getCreatePromise("b.md"); + const promiseA = queue.getLatestCreatePromise("a.md"); + const promiseB = queue.getLatestCreatePromise("b.md"); assert.ok(promiseA !== undefined); assert.ok(promiseB !== undefined); @@ -481,7 +481,7 @@ describe("SyncEventQueue", () => { const queue = createQueue(); queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - const createPromise = queue.getCreatePromise("a.md")!; + const createPromise = queue.getLatestCreatePromise("a.md")!; // Dependent events enqueued while create is still pending queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index d6859ead..7697ac9c 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -1,7 +1,7 @@ import type { Settings } from "../persistence/settings"; import type { Logger } from "../tracing/logger"; import { globsToRegexes } from "../utils/globs-to-regexes"; -import { isConflictPath } from "./conflict-path"; +import { CONFLICT_PATH_REGEX } from "./conflict-path"; import { removeFromArray } from "../utils/remove-from-array"; import { SyncEventType, @@ -44,7 +44,7 @@ export class SyncEventQueue { private savePending = false; - private readonly lastSeenUpdateId: VaultUpdateId; + public readonly lastSeenUpdateId: VaultUpdateId; public constructor( private readonly settings: Settings, @@ -52,16 +52,19 @@ export class SyncEventQueue { initialState: Partial | undefined, private readonly saveData: (data: StoredSyncState) => Promise ) { - this.ignorePatterns = globsToRegexes( - this.settings.getSettings().ignorePatterns, - this.logger - ); + this.ignorePatterns = [ + CONFLICT_PATH_REGEX, + ...globsToRegexes( + this.settings.getSettings().ignorePatterns, + this.logger + ) + ]; this.settings.onSettingsChanged.add((newSettings) => { - this.ignorePatterns = globsToRegexes( - newSettings.ignorePatterns, - this.logger - ); + this.ignorePatterns = [ + CONFLICT_PATH_REGEX, + ...globsToRegexes(newSettings.ignorePatterns, this.logger) + ]; }); initialState ??= {}; @@ -84,6 +87,100 @@ export class SyncEventQueue { return this.documents.size; } + public enqueue(input: FileSyncEvent): void { + if (input.type === SyncEventType.RemoteUpdate) { + this.events.push(input); + return; + } + + const { path } = input; + + if (this.isIgnored(path)) { + this.logger.info( + `Ignoring ${input.type} for ${path} as it matches ignore patterns` + ); + return; + } + + if (input.type === SyncEventType.LocalCreate) { + this.events.push({ type: SyncEventType.LocalCreate, path, originalPath: path }); + return; + } + + const lookupPath = (input.type === SyncEventType.LocalUpdate && input.oldPath) ? input.oldPath : path; + const record = this.documents.get(lookupPath); + const documentId: DocumentId | Promise | undefined = + this.getLatestCreatePromise(lookupPath) ?? record?.documentId; + if (documentId === undefined) return; + + if (input.type === SyncEventType.LocalDelete) { + this.events.push({ type: SyncEventType.LocalDelete, documentId }); + return; + } + + if (input.oldPath !== undefined) { + if (typeof documentId === "string") { + this.documents.delete(input.oldPath); + this.documents.set(path, record!); + for (const e of this.events) { + // It already has a docId, so there can't be a pending create event for it + if (e.type === SyncEventType.LocalUpdate && e.documentId === documentId) { + e.path = path; + } + } + this.saveInTheBackground(); + } else { + this.updatePendingCreatePath(input.oldPath, path); + } + } + this.events.push({ type: SyncEventType.LocalUpdate, documentId, path, originalPath: path }); + } + + + + public async next(): Promise { + return this.events.shift(); + } + + + /** + * Call once a create has been acknowledged by the server. + */ + public resolveCreate( + event: Extract, + record: DocumentRecord + ): void { + const promise = event.resolvers?.promise; + + this.documents.set(event.path, record); + event.resolvers?.resolve(record.documentId); + + if (promise !== undefined) { + for (const e of this.events) { + if ( + (e.type === SyncEventType.LocalUpdate || e.type === SyncEventType.LocalDelete) && + e.documentId === promise + ) { + (e as { documentId: DocumentId | Promise }).documentId = record.documentId; + } + } + } + + this.saveInTheBackground(); + } + + public async save(): Promise { + return this.saveData({ + documents: Array.from(this.documents.entries()).map( + ([relativePath, record]) => ({ + relativePath, + ...record + }) + ), + lastSeenUpdateId: this.lastSeenUpdateId + }); + } + // todo: let's remove public getSettledDocumentByPath(path: RelativePath): DocumentRecord | undefined { return this.documents.get(path); @@ -110,87 +207,10 @@ export class SyncEventQueue { this.saveInTheBackground(); } - /** - * Reflect a local rename in the queue's disk-path index. - * - * Mirrors the `input.oldPath !== undefined` branch of `enqueue`, but - * without emitting a new `SyncLocal` — used by `FileOperations.move` - * when the rename is a byproduct of another sync operation (e.g. the - * user dragging a file) and the caller will push the resulting event - * separately, or not at all. - * - * If the rename targets a path that already holds a settled record - * (e.g. concurrent clobber), the destination's record is dropped: the - * caller is expected to have moved the displaced file out of the way - * via `ensureClearPath` already, so the dropped record reflects the - * now-orphaned disk state. - */ - public moveDocument( - oldPath: RelativePath, - newPath: RelativePath - ): void { - if (oldPath === newPath) return; - const record = this.documents.get(oldPath); - if (record !== undefined) { - // If `newPath` already holds a settled record, overwriting it - // silently would orphan that document's identity. Warn so the - // bug is visible; the caller is expected to have freed the - // destination via `ensureClearPath` first. - const clobbered = this.documents.get(newPath); - if (clobbered !== undefined) { - this.logger.warn( - `moveDocument(${oldPath} → ${newPath}) is overwriting a settled record for document ${clobbered.documentId}; caller should have displaced it first` - ); - } - this.documents.delete(oldPath); - this.documents.set(newPath, record); - for (const e of this.events) { - if ( - e.type === SyncEventType.LocalUpdate && - e.documentId === record.documentId - ) { - e.path = newPath; - } - } - this.saveInTheBackground(); - return; - } - - // No settled record — the rename may be over a pending Create - // whose document hasn't been persisted on the server yet. - this.updatePendingCreatePath(oldPath, newPath); - } - - /** - * Call once a create has been acknowledged by the server. - */ - public resolveCreate( - event: Extract, - record: DocumentRecord - ): void { - const promise = event.resolvers?.promise; - - this.documents.set(event.path, record); - event.resolvers?.resolve(record.documentId); - - if (promise !== undefined) { - for (const e of this.events) { - if ( - (e.type === SyncEventType.LocalUpdate || e.type === SyncEventType.LocalDelete) && - e.documentId === promise - ) { - (e as { documentId: DocumentId | Promise }).documentId = record.documentId; - } - } - } - - this.saveInTheBackground(); - } - - public getCreatePromise(path: RelativePath): Promise | undefined { - const event = this.findLastCreate(path); + public getLatestCreatePromise(path: RelativePath): Promise | undefined { + const event = this.findLatestCreate(path); if (event === undefined) return undefined; event.resolvers ??= Promise.withResolvers(); return event.resolvers.promise; @@ -254,17 +274,6 @@ export class SyncEventQueue { ); } - public async save(): Promise { - return this.saveData({ - documents: Array.from(this.documents.entries()).map( - ([relativePath, record]) => ({ - relativePath, - ...record - }) - ), - lastSeenUpdateId: this.lastSeenUpdateId - }); - } public resetState(): void { this.rejectAllPendingCreates(); @@ -277,161 +286,11 @@ export class SyncEventQueue { this.events.length = 0; } - public enqueue(input: FileSyncEvent): void { - if (input.type === SyncEventType.RemoteUpdate) { - this.events.push(input); - return; - } - - const { path } = input; - - // Conflict-displaced files are local-only bookkeeping so a conflict - // hit is a debug-level event. A hit against a user-configured glob - // is a higher-signal "we're deliberately not syncing this" and - // stays at info. - if (isConflictPath(path)) { - this.logger.debug( - `Ignoring ${input.type} for ${path}: conflict-displaced file` - ); - return; - } - if (this.matchesUserIgnorePattern(path)) { - this.logger.info( - `Ignoring ${input.type} for ${path} as it matches ignore patterns` - ); - return; - } - - if (input.type === SyncEventType.LocalCreate) { - this.events.push({ type: SyncEventType.LocalCreate, path, originalPath: path }); - return; - } - - const lookupPath = (input.type === SyncEventType.LocalUpdate && input.oldPath) ? input.oldPath : path; - const record = this.documents.get(lookupPath); - const documentId: DocumentId | Promise | undefined = - record?.documentId ?? this.getCreatePromise(lookupPath); - if (documentId === undefined) return; - - if (input.type === SyncEventType.LocalDelete) { - this.events.push({ type: SyncEventType.LocalDelete, documentId }); - return; - } - - if (input.oldPath !== undefined) { - if (typeof documentId === "string") { - this.documents.delete(input.oldPath); - this.documents.set(path, record!); - for (const e of this.events) { - if (e.type === SyncEventType.LocalUpdate && e.documentId === documentId) { - e.path = path; - } - } - this.saveInTheBackground(); - } else { - this.updatePendingCreatePath(input.oldPath, path); - } - } - this.events.push({ type: SyncEventType.LocalUpdate, documentId, path, originalPath: path }); - } - public async next(): Promise { - if (this.events.length === 0) return undefined; - - const [first] = this.events; - - // Creates are always returned immediately (FIFO) - if (first.type === SyncEventType.LocalCreate) { - this.events.shift(); - return first; - } - - // Deletes are returned immediately; also discard any subsequent - // events for the same documentId so stale broadcasts don't - // resurrect the document. If the documentId is still a pending - // `Promise` (the originating Create hasn't landed - // yet), awaiting it may reject — handle that: the Create was - // cancelled, so the Delete has nothing to delete, just drop it. - if (first.type === SyncEventType.LocalDelete) { - this.events.shift(); - const { documentId } = first; - let resolvedId: DocumentId; - try { - resolvedId = await documentId; - } catch { - this.logger.debug( - "Dropping Delete whose Create was cancelled before it could be synced" - ); - return this.next(); - } - this.removeAllEventsForDocumentId(resolvedId); - return first; - } - - if (first.type === SyncEventType.LocalUpdate) { - const { documentId } = first; - - // If there's a later delete for the same documentId, discard - // all sync-locals for that document and return the delete - const deleteEvent = this.events.find( - (e) => - e.type === SyncEventType.LocalDelete && - e.documentId === documentId - ); - if (deleteEvent !== undefined) { - let resolvedId: DocumentId; - try { - resolvedId = await documentId; - } catch { - this.logger.debug( - "Dropping SyncLocal+Delete whose Create was cancelled before it could be synced" - ); - return this.next(); - } - this.removeAllEventsForDocumentId(resolvedId); - return deleteEvent; - } - - // Coalesce multiple sync-locals for the same documentId and - // original path to the last one - const matching = this.events.filter( - (e) => - e.type === SyncEventType.LocalUpdate && - e.documentId === documentId && - e.originalPath === first.originalPath // can't coalesce moves as they can depend on each other so we have to sync them in the same order, could do topological sort but let's keep it simple for now - ); - const result = matching[matching.length - 1]; - for (const item of matching) { - removeFromArray(this.events, item); - } - return result; - } - - // Coalesce multiple RemoteUpdate events for the same documentId - // down to the last one — the `.next` walk already short-circuits - // on obsolete versions via `parentVersionId` checks, but compacting - // here keeps the queue bounded under burst remote activity. - const { documentId } = first.remoteVersion; - const matching = this.events.filter( - (e) => - e.type === SyncEventType.RemoteUpdate && - e.remoteVersion.documentId === documentId - ); - const result = matching[matching.length - 1]; - for (const item of matching) { - removeFromArray(this.events, item); - } - return result; - } - - private matchesUserIgnorePattern(path: RelativePath): boolean { - return this.ignorePatterns.some((pattern) => pattern.test(path)); - } - private isIgnored(path: RelativePath): boolean { - return isConflictPath(path) || this.matchesUserIgnorePattern(path); + return this.ignorePatterns.some((pattern) => pattern.test(path)); } public removeAllEventsForDocumentId(documentId: DocumentId): void { @@ -455,7 +314,7 @@ export class SyncEventQueue { oldPath: RelativePath, newPath: RelativePath ): void { - const createEvent = this.findLastCreate(oldPath); + const createEvent = this.findLatestCreate(oldPath); if (createEvent === undefined) return; const promise = createEvent.resolvers?.promise; @@ -473,22 +332,7 @@ export class SyncEventQueue { } } - private findCreatePathByPromise( - promise: Promise - ): RelativePath | undefined { - for (let i = this.events.length - 1; i >= 0; i--) { - const e = this.events[i]; - if ( - e.type === SyncEventType.LocalCreate && - e.resolvers?.promise === promise - ) { - return e.path; - } - } - return undefined; - } - - private findLastCreate( + private findLatestCreate( path: RelativePath ): Extract | undefined { for (let i = this.events.length - 1; i >= 0; i--) { @@ -506,7 +350,7 @@ export class SyncEventQueue { * merging it with a concurrent remote create. */ public hasPendingCreateAt(path: RelativePath): boolean { - return this.findLastCreate(path) !== undefined; + return this.findLatestCreate(path) !== undefined; } /** @@ -517,7 +361,7 @@ export class SyncEventQueue { * and cancelled. */ public cancelPendingCreate(path: RelativePath): boolean { - const event = this.findLastCreate(path); + const event = this.findLatestCreate(path); if (event === undefined) return false; if (event.resolvers !== undefined) { diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 9d009c3f..6e123edc 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -44,7 +44,7 @@ export class Syncer { private readonly queue: SyncEventQueue; - private _isFirstSyncComplete = false; + private _isFirstSyncStarted = false; private runningScheduleSyncForOfflineChanges: Promise | undefined; private draining: Promise | undefined; private previousRemainingOperationsCount = 0; @@ -66,14 +66,6 @@ export class Syncer { this.webSocketManager.onWebSocketStatusChanged.add((isConnected) => { if (isConnected) { this.sendHandshakeMessage(); - // The server no longer carries an `is_initial_sync` - // terminator: it streams missed versions as individual - // VaultUpdates and then behaves like a live subscription. - // Mark first-sync as complete once we've observed the - // transition to "connected" — per-path sync status still - // relies on `hasPendingEventsForPath`, which correctly - // shows SYNCING while catch-up events are in flight. - this._isFirstSyncComplete = true; } }); this.webSocketManager.onRemoteVaultUpdateReceived.add( @@ -82,7 +74,7 @@ export class Syncer { } public get isFirstSyncComplete(): boolean { - return this._isFirstSyncComplete; + return this._isFirstSyncStarted; } public syncLocallyCreatedFile(relativePath: RelativePath): void { @@ -110,11 +102,7 @@ export class Syncer { } - // Handler for every `WebSocketVaultUpdate` the server emits. The - // server filters out messages authored by this device, so every - // update here comes from a peer (or is part of the catch-up stream - // the server replays on connect for versions we missed while - // offline). + public async syncRemotelyUpdatedFile( message: WebSocketVaultUpdate ): Promise { @@ -126,6 +114,8 @@ export class Syncer { }); this.ensureDraining(); + + this._isFirstSyncStarted = true; } public async scheduleSyncForOfflineChanges(): Promise { @@ -167,7 +157,7 @@ export class Syncer { public reset(): void { - this._isFirstSyncComplete = false; + this._isFirstSyncStarted = false; this.queue.clear(); // Don't null the reference synchronously — if the scan is // still in flight, the next reconnect would spawn a second @@ -220,14 +210,12 @@ export class Syncer { ); }); - await this.scheduleDrain(); + this.ensureDraining(); + await this.draining; } - private ensureDraining(): void { - void this.chainOntoDrain(async () => this.drain()); - } /** * Serialize a unit of work onto the same promise chain the drain @@ -248,12 +236,11 @@ export class Syncer { ); return chained; } - - private async scheduleDrain(): Promise { - this.ensureDraining(); - await this.draining; + private ensureDraining(): void { + void this.chainOntoDrain(async () => this.drain()); } + private async drain(): Promise { let event = await this.queue.next(); while (event !== undefined) { diff --git a/frontend/sync-client/src/sync-operations/types.ts b/frontend/sync-client/src/sync-operations/types.ts index 4a201ad9..57cd8a6f 100644 --- a/frontend/sync-client/src/sync-operations/types.ts +++ b/frontend/sync-client/src/sync-operations/types.ts @@ -16,7 +16,7 @@ export interface StoredDocument extends DocumentRecord { } export interface StoredSyncState { - documents: StoredDocument[]; + documents: StoredDocument[] | undefined; lastSeenUpdateId: VaultUpdateId | undefined; } diff --git a/sync-server/src/server.rs b/sync-server/src/server.rs index 0835e9b6..934e9428 100644 --- a/sync-server/src/server.rs +++ b/sync-server/src/server.rs @@ -14,7 +14,6 @@ mod ping; mod rate_limit; mod requests; mod responses; -mod restore_document_version; mod update_document; mod websocket; @@ -174,10 +173,6 @@ fn get_authed_routes(app_state: AppState) -> Router { "/vaults/:vault_id/documents/:document_id", delete(delete_document::delete_document), ) - .route( - "/vaults/:vault_id/documents/:document_id/restore", - post(restore_document_version::restore_document_version), - ) .route( "/vaults/:vault_id/history", get(fetch_vault_history::fetch_vault_history), diff --git a/sync-server/src/server/restore_document_version.rs b/sync-server/src/server/restore_document_version.rs deleted file mode 100644 index 5a806edd..00000000 --- a/sync-server/src/server/restore_document_version.rs +++ /dev/null @@ -1,186 +0,0 @@ -use anyhow::anyhow; -use axum::{ - Extension, Json, - extract::{Path, State}, -}; -use axum_extra::TypedHeader; -use log::{debug, info}; -use serde::Deserialize; - -use super::device_id_header::DeviceIdHeader; -use crate::{ - app_state::{ - AppState, - database::{ - InsertBroadcast, - models::{ - DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, - VaultUpdateId, - }, - }, - }, - config::user_config::User, - errors::{ - SyncServerError, client_error, not_found_error, server_error, write_transaction_error, - }, - utils::{find_first_available_path::find_first_available_path, normalize::normalize}, -}; - -#[derive(Deserialize)] -pub struct RestorePathParams { - #[serde(deserialize_with = "normalize")] - vault_id: VaultId, - - document_id: DocumentId, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RestoreDocumentVersionRequest { - pub vault_update_id: VaultUpdateId, -} - -#[axum::debug_handler] -pub async fn restore_document_version( - Path(RestorePathParams { - vault_id, - document_id, - }): Path, - Extension(user): Extension, - TypedHeader(device_id): TypedHeader, - State(state): State, - Json(request): Json, -) -> Result, SyncServerError> { - debug!( - "Restoring document `{document_id}` in vault `{vault_id}` to version `{}`", - request.vault_update_id - ); - - if request.vault_update_id <= 0 { - return Err(client_error(anyhow!( - "Invalid vault_update_id: `{}`", - request.vault_update_id - ))); - } - - let mut transaction = state - .database - .create_write_transaction(&vault_id) - .await - .map_err(write_transaction_error)?; - - let target_version = state - .database - .get_document_version(&vault_id, request.vault_update_id, Some(&mut *transaction)) - .await - .map_err(server_error)? - .ok_or_else(|| { - not_found_error(anyhow!("Version `{}` not found", request.vault_update_id)) - })?; - - if target_version.document_id != document_id { - transaction.rollback().await.map_err(server_error)?; - return Err(not_found_error(anyhow!( - "Version `{}` does not belong to document `{document_id}`", - request.vault_update_id, - ))); - } - - if target_version.is_deleted { - transaction.rollback().await.map_err(server_error)?; - return Err(client_error(anyhow!( - "Cannot restore to a deleted version `{}`", - request.vault_update_id, - ))); - } - - let existing = state - .database - .get_latest_non_deleted_document_by_path( - &vault_id, - &target_version.relative_path, - Some(&mut *transaction), - ) - .await - .map_err(server_error)?; - - let restore_path = if let Some(existing_doc) = &existing - && existing_doc.document_id != document_id - { - find_first_available_path( - &vault_id, - &target_version.relative_path, - &state.database, - &mut transaction, - ) - .await - .map_err(server_error)? - } else { - target_version.relative_path.clone() - }; - - let last_update_id = state - .database - .get_max_update_id_in_vault(&vault_id, Some(&mut *transaction)) - .await - .map_err(server_error)?; - - // The current latest (pre-restore) is our baseline for deciding - // whether content and/or path actually change. - let current_latest = state - .database - .get_latest_document(&vault_id, &document_id, Some(&mut *transaction)) - .await - .map_err(server_error)?; - - let new_version = StoredDocumentVersion { - vault_update_id: last_update_id + 1, - creation_vault_update_id: target_version.creation_vault_update_id, - document_id, - relative_path: restore_path, - content: target_version.content, - updated_date: chrono::Utc::now(), - is_deleted: false, - user_id: user.name.clone(), - device_id: device_id.0.clone(), - has_been_merged: false, - }; - - let (content_changed, path_changed) = match ¤t_latest { - Some(prev) => ( - prev.content != new_version.content || prev.is_deleted, - // Mirror `update_document`: `path_changed` is true when the - // stored path differs from either the prior stored path (peers - // need to learn about the move) *or* from the path the caller - // implicitly requested (`target_version.relative_path`, so the - // origin learns if the server deduped its requested restore - // path). - prev.relative_path != new_version.relative_path - || target_version.relative_path != new_version.relative_path, - ), - // No prior version (shouldn't happen in practice — target_version - // already proved the document exists — but treat defensively). - None => (true, true), - }; - - state - .database - .insert_document_version( - &vault_id, - &new_version, - transaction, - InsertBroadcast { - content_changed, - path_changed, - }, - ) - .await - .map_err(server_error)?; - - info!( - "Restored document `{document_id}` to version `{}` as new version `{}`", - request.vault_update_id, new_version.vault_update_id - ); - - Ok(Json(new_version.into())) -} From aecbcd1d2c1abfd0947149094327060020127408 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 08:40:40 +0100 Subject: [PATCH 12/52] return paths --- .../lib/types/DocumentUpdateMergedContent.ts | 9 --- .../src/lib/types/DocumentUpdateMetadata.ts | 9 --- .../src/lib/types/DocumentUpdateResponse.ts | 6 +- .../file-operations/file-operations.test.ts | 8 +-- .../src/file-operations/file-operations.ts | 2 +- .../types/DocumentUpdateMergedContent.ts | 9 --- .../services/types/DocumentUpdateMetadata.ts | 9 --- .../services/types/DocumentUpdateResponse.ts | 6 +- .../src/sync-operations/conflict-path.test.ts | 12 ++-- .../src/sync-operations/conflict-path.ts | 13 ---- sync-server/src/app_state/database/models.rs | 66 ------------------- sync-server/src/server/responses.rs | 7 +- 12 files changed, 20 insertions(+), 136 deletions(-) delete mode 100644 frontend/history-ui/src/lib/types/DocumentUpdateMergedContent.ts delete mode 100644 frontend/history-ui/src/lib/types/DocumentUpdateMetadata.ts delete mode 100644 frontend/sync-client/src/services/types/DocumentUpdateMergedContent.ts delete mode 100644 frontend/sync-client/src/services/types/DocumentUpdateMetadata.ts diff --git a/frontend/history-ui/src/lib/types/DocumentUpdateMergedContent.ts b/frontend/history-ui/src/lib/types/DocumentUpdateMergedContent.ts deleted file mode 100644 index 5fca495f..00000000 --- a/frontend/history-ui/src/lib/types/DocumentUpdateMergedContent.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Like [`DocumentVersion`] but without the `relative_path`. - * Used only in create/update responses when the server had to merge the - * client's content with a newer remote version and therefore must echo - * the merged content back. - */ -export type DocumentUpdateMergedContent = { vaultUpdateId: number, documentId: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }; diff --git a/frontend/history-ui/src/lib/types/DocumentUpdateMetadata.ts b/frontend/history-ui/src/lib/types/DocumentUpdateMetadata.ts deleted file mode 100644 index 393713fb..00000000 --- a/frontend/history-ui/src/lib/types/DocumentUpdateMetadata.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Like [`DocumentVersionWithoutContent`] but without the `relative_path`. - * Used only in create/update responses where the client already tracks - * the path locally (the server is the source of truth for the - * document identity, not its path). - */ -export type DocumentUpdateMetadata = { vaultUpdateId: number, documentId: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, }; diff --git a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts b/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts index 4e2ef3ab..51e0b37c 100644 --- a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts +++ b/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts @@ -1,8 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DocumentUpdateMergedContent } from "./DocumentUpdateMergedContent"; -import type { DocumentUpdateMetadata } from "./DocumentUpdateMetadata"; +import type { DocumentVersion } from "./DocumentVersion"; +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; /** * Response to a create/update document request. */ -export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentUpdateMetadata | { "type": "MergingUpdate" } & DocumentUpdateMergedContent; +export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion; diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index 5a8f5af6..a69a5429 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -8,7 +8,7 @@ import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly"; import type { FileSystemOperations } from "./filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; import type { ServerConfig, ServerConfigData } from "../services/server-config"; -import { isConflictPath } from "../sync-operations/conflict-path"; +import { CONFLICT_PATH_REGEX } from "../sync-operations/conflict-path"; class MockServerConfig implements Pick { public async getConfig(): Promise { @@ -108,7 +108,7 @@ function singleConflictPath( `expected exactly one conflict-path entry, got ${JSON.stringify(conflicts)}` ); assert.ok( - isConflictPath(conflicts[0]), + CONFLICT_PATH_REGEX.test(conflicts[0]), `expected ${conflicts[0]} to match the conflict-path pattern` ); return conflicts[0]; @@ -195,7 +195,7 @@ describe("File operations", () => { (name) => name !== ".gitignore" && name !== ".config.json" ); assert.equal(conflicts.length, 2); - assert.ok(conflicts.every(isConflictPath)); + assert.ok(conflicts.every((c) => CONFLICT_PATH_REGEX.test(c))); assert.ok(conflicts.some((c) => c.endsWith("-.gitignore"))); assert.ok(conflicts.some((c) => c.endsWith("-.config.json"))); }); @@ -209,7 +209,7 @@ describe("File operations", () => { const conflicts = Array.from(fs.names).filter((n) => n !== "x"); assert.equal(conflicts.length, 2); - assert.ok(conflicts.every(isConflictPath)); + assert.ok(conflicts.every((c) => CONFLICT_PATH_REGEX.test(c))); assert.notEqual( conflicts[0], conflicts[1], diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index e2ffd4a5..530a6bcc 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -64,7 +64,7 @@ export class FileOperations { * * If a file is already there, it is moved aside to a `conflict--` * path in the same directory. The sync layer treats conflict-named files - * as invisible (see `isConflictPath`), so no events are enqueued and no + * as invisible (see `CONFLICT_PATH_REGEX`), so no events are enqueued and no * document records are touched — any pre-existing record or pending * events for the displaced path are left behind for the caller to * overwrite as part of whatever operation prompted the displacement. diff --git a/frontend/sync-client/src/services/types/DocumentUpdateMergedContent.ts b/frontend/sync-client/src/services/types/DocumentUpdateMergedContent.ts deleted file mode 100644 index 4e0e4af4..00000000 --- a/frontend/sync-client/src/services/types/DocumentUpdateMergedContent.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Like [`DocumentVersion`] but without the `relative_path`. - * Used only in create/update responses when the server had to merge the - * client's content with a newer remote version and therefore must echo - * the merged content back. - */ -export interface DocumentUpdateMergedContent { vaultUpdateId: number, documentId: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, } diff --git a/frontend/sync-client/src/services/types/DocumentUpdateMetadata.ts b/frontend/sync-client/src/services/types/DocumentUpdateMetadata.ts deleted file mode 100644 index 325d896a..00000000 --- a/frontend/sync-client/src/services/types/DocumentUpdateMetadata.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Like [`DocumentVersionWithoutContent`] but without the `relative_path`. - * Used only in create/update responses where the client already tracks - * the path locally (the server is the source of truth for the - * document identity, not its path). - */ -export interface DocumentUpdateMetadata { vaultUpdateId: number, documentId: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, } diff --git a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts index 4e2ef3ab..51e0b37c 100644 --- a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts +++ b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts @@ -1,8 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DocumentUpdateMergedContent } from "./DocumentUpdateMergedContent"; -import type { DocumentUpdateMetadata } from "./DocumentUpdateMetadata"; +import type { DocumentVersion } from "./DocumentVersion"; +import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; /** * Response to a create/update document request. */ -export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentUpdateMetadata | { "type": "MergingUpdate" } & DocumentUpdateMergedContent; +export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion; diff --git a/frontend/sync-client/src/sync-operations/conflict-path.test.ts b/frontend/sync-client/src/sync-operations/conflict-path.test.ts index ba39c238..7f7bf67c 100644 --- a/frontend/sync-client/src/sync-operations/conflict-path.test.ts +++ b/frontend/sync-client/src/sync-operations/conflict-path.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert"; -import { buildConflictFileName, isConflictPath } from "./conflict-path"; +import { buildConflictFileName, CONFLICT_PATH_REGEX } from "./conflict-path"; describe("buildConflictFileName", () => { it("truncates to the filesystem byte limit while preserving the extension", () => { @@ -59,20 +59,20 @@ describe("buildConflictFileName", () => { }); }); -describe("isConflictPath", () => { +describe("CONFLICT_PATH_REGEX", () => { it("does not misclassify user-authored names that start with `conflict-`", () => { - assert.strictEqual(isConflictPath("conflict-resolution.md"), false); + assert.strictEqual(CONFLICT_PATH_REGEX.test("conflict-resolution.md"), false); }); it("only inspects the final path segment", () => { assert.strictEqual( - isConflictPath( + CONFLICT_PATH_REGEX.test( "conflict-12345678-1234-1234-1234-123456789abc-x/note.md" ), false ); assert.strictEqual( - isConflictPath( + CONFLICT_PATH_REGEX.test( "a/b/conflict-12345678-1234-1234-1234-123456789abc-note.md" ), true @@ -80,6 +80,6 @@ describe("isConflictPath", () => { }); it("round-trips with buildConflictFileName", () => { - assert.strictEqual(isConflictPath(buildConflictFileName("note.md")), true); + assert.strictEqual(CONFLICT_PATH_REGEX.test(buildConflictFileName("note.md")), true); }); }); diff --git a/frontend/sync-client/src/sync-operations/conflict-path.ts b/frontend/sync-client/src/sync-operations/conflict-path.ts index bd1d7c0b..7a634bb4 100644 --- a/frontend/sync-client/src/sync-operations/conflict-path.ts +++ b/frontend/sync-client/src/sync-operations/conflict-path.ts @@ -1,5 +1,3 @@ -import type { RelativePath } from "./types"; - // Local-only files displaced by `FileOperations.ensureClearPath` are named // `conflict--`. The UUID is a full RFC-4122 v4 value so // a user-authored filename that happens to start with `conflict-` doesn't @@ -55,14 +53,3 @@ function truncateFileNameToByteLimit( } return truncatedStem + extension; } - -/** - * Is `path`'s final segment a conflict-displaced filename? - * - * Any sync code that would otherwise create/update/delete/sync the path - * should short-circuit when this returns true: conflict-displaced files are - * strictly local and must stay invisible to the server. - */ -export function isConflictPath(path: RelativePath): boolean { - return CONFLICT_PATH_REGEX.test(path); -} diff --git a/sync-server/src/app_state/database/models.rs b/sync-server/src/app_state/database/models.rs index 80b628e8..b0e19c49 100644 --- a/sync-server/src/app_state/database/models.rs +++ b/sync-server/src/app_state/database/models.rs @@ -78,72 +78,6 @@ pub struct DocumentVersion { pub device_id: DeviceId, } -/// Like [`DocumentVersionWithoutContent`] but without the `relative_path`. -/// Used only in create/update responses where the client already tracks -/// the path locally (the server is the source of truth for the -/// document identity, not its path). -#[derive(TS, Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DocumentUpdateMetadata { - #[ts(type = "number")] - pub vault_update_id: VaultUpdateId, - - pub document_id: DocumentId, - pub updated_date: DateTime, - pub is_deleted: bool, - pub user_id: UserId, - pub device_id: DeviceId, - - #[ts(type = "number")] - pub content_size: u64, -} - -impl From for DocumentUpdateMetadata { - fn from(value: StoredDocumentVersion) -> Self { - Self { - vault_update_id: value.vault_update_id, - document_id: value.document_id, - updated_date: value.updated_date, - is_deleted: value.is_deleted, - user_id: value.user_id, - device_id: value.device_id, - content_size: value.content.len() as u64, - } - } -} - -/// Like [`DocumentVersion`] but without the `relative_path`. -/// Used only in create/update responses when the server had to merge the -/// client's content with a newer remote version and therefore must echo -/// the merged content back. -#[derive(TS, Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct DocumentUpdateMergedContent { - #[ts(type = "number")] - pub vault_update_id: VaultUpdateId, - - pub document_id: DocumentId, - pub updated_date: DateTime, - pub content_base64: String, - pub is_deleted: bool, - pub user_id: UserId, - pub device_id: DeviceId, -} - -impl From for DocumentUpdateMergedContent { - fn from(value: StoredDocumentVersion) -> Self { - Self { - vault_update_id: value.vault_update_id, - document_id: value.document_id, - updated_date: value.updated_date, - content_base64: STANDARD.encode(&value.content), - is_deleted: value.is_deleted, - user_id: value.user_id, - device_id: value.device_id, - } - } -} - /// Row struct for vault history queries (used by `sqlx::query_as!`) #[derive(Debug)] pub struct VaultHistoryRow { diff --git a/sync-server/src/server/responses.rs b/sync-server/src/server/responses.rs index 18158e65..f5b30782 100644 --- a/sync-server/src/server/responses.rs +++ b/sync-server/src/server/responses.rs @@ -3,8 +3,7 @@ use serde::{self, Serialize}; use ts_rs::TS; use crate::app_state::database::models::{ - DocumentUpdateMergedContent, DocumentUpdateMetadata, DocumentVersionWithoutContent, - VaultUpdateId, + DocumentVersion, DocumentVersionWithoutContent, VaultUpdateId, }; /// Response to a ping request. @@ -75,9 +74,9 @@ pub enum DocumentUpdateResponse { /// Returned when the created/updated document's content is the same as was /// sent in the create/update request and thus the response doesn't contain /// the content because the client must already have it. - FastForwardUpdate(DocumentUpdateMetadata), + FastForwardUpdate(DocumentVersionWithoutContent), /// Returned when the created/updated document's content is different from /// what was sent in the create/update request. - MergingUpdate(DocumentUpdateMergedContent), + MergingUpdate(DocumentVersion), } From 7293c58a71f1b659477205623d55e6f73da3ead9 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 12:43:47 +0100 Subject: [PATCH 13/52] good --- .../sync-operations/sync-event-queue.test.ts | 16 +- .../src/sync-operations/sync-event-queue.ts | 159 ++-- .../sync-client/src/sync-operations/syncer.ts | 901 ++++++------------ .../sync-client/src/sync-operations/types.ts | 10 +- 4 files changed, 370 insertions(+), 716 deletions(-) diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts index db6c9a19..23f31891 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts @@ -71,21 +71,21 @@ describe("SyncEventQueue", () => { const queue = createQueue(); queue.enqueue({ - type: SyncEventType.RemoteUpdate, + type: SyncEventType.RemoteChange, remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 1 }) }); queue.enqueue({ - type: SyncEventType.RemoteUpdate, + type: SyncEventType.RemoteChange, remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 2 }) }); queue.enqueue({ - type: SyncEventType.RemoteUpdate, + type: SyncEventType.RemoteChange, remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 3 }) }); const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.RemoteUpdate); - if (event?.type === SyncEventType.RemoteUpdate) { + assert.strictEqual(event?.type, SyncEventType.RemoteChange); + if (event?.type === SyncEventType.RemoteChange) { assert.strictEqual(event.remoteVersion.vaultUpdateId, 3); } assert.strictEqual(await queue.next(), undefined); @@ -217,7 +217,7 @@ describe("SyncEventQueue", () => { queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); queue.enqueue({ - type: SyncEventType.RemoteUpdate, + type: SyncEventType.RemoteChange, remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 5 }) }); @@ -238,7 +238,7 @@ describe("SyncEventQueue", () => { queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); queue.enqueue({ - type: SyncEventType.RemoteUpdate, + type: SyncEventType.RemoteChange, remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 5 }) }); @@ -342,7 +342,7 @@ describe("SyncEventQueue", () => { assert.strictEqual(queue.pendingUpdateCount, 1); queue.enqueue({ - type: SyncEventType.RemoteUpdate, + type: SyncEventType.RemoteChange, remoteVersion: fakeRemoteVersion("N") }); assert.strictEqual(queue.pendingUpdateCount, 2); diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index 7697ac9c..401541b1 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -13,10 +13,7 @@ import { type SyncEvent, type VaultUpdateId, } from "./types"; -import { sleep } from "../utils/sleep"; -export const SAVE_RETRY_BASE_DELAY_MS = 50; -export const SAVE_RETRY_MAX_ATTEMPTS = 3; export class SyncEventQueue { // Latest state of the filesystem as we know it, excluding @@ -88,14 +85,14 @@ export class SyncEventQueue { } public enqueue(input: FileSyncEvent): void { - if (input.type === SyncEventType.RemoteUpdate) { + if (input.type === SyncEventType.RemoteChange) { this.events.push(input); return; } const { path } = input; - if (this.isIgnored(path)) { + if (this.ignorePatterns.some((pattern) => pattern.test(path))) { this.logger.info( `Ignoring ${input.type} for ${path} as it matches ignore patterns` ); @@ -103,15 +100,20 @@ export class SyncEventQueue { } if (input.type === SyncEventType.LocalCreate) { - this.events.push({ type: SyncEventType.LocalCreate, path, originalPath: path }); + this.events.push({ type: SyncEventType.LocalCreate, path, originalPath: path, resolvers: Promise.withResolvers() }); return; } const lookupPath = (input.type === SyncEventType.LocalUpdate && input.oldPath) ? input.oldPath : path; const record = this.documents.get(lookupPath); const documentId: DocumentId | Promise | undefined = - this.getLatestCreatePromise(lookupPath) ?? record?.documentId; - if (documentId === undefined) return; + this.findLatestCreateForPath(lookupPath)?.resolvers.promise ?? record?.documentId; + + if (documentId === undefined) { + // we can get here when deleting a local document after a remote update + + return; + } if (input.type === SyncEventType.LocalDelete) { this.events.push({ type: SyncEventType.LocalDelete, documentId }); @@ -146,29 +148,56 @@ export class SyncEventQueue { /** * Call once a create has been acknowledged by the server. */ - public resolveCreate( + public async resolveCreate( event: Extract, record: DocumentRecord - ): void { - const promise = event.resolvers?.promise; - - this.documents.set(event.path, record); + ): Promise { + removeFromArray(this.events, event); // in case the create event is still pending + await this.setDocument(event.path, record); event.resolvers?.resolve(record.documentId); + } - if (promise !== undefined) { - for (const e of this.events) { - if ( - (e.type === SyncEventType.LocalUpdate || e.type === SyncEventType.LocalDelete) && - e.documentId === promise - ) { - (e as { documentId: DocumentId | Promise }).documentId = record.documentId; - } + /** + * Update the settled document map and persist the new document version. + */ + public setDocument(path: RelativePath, record: DocumentRecord): Promise { + this.documents.set(path, record); + return this.save(); + + } + + public removeDocument(path: RelativePath): Promise { + this.documents.delete(path); + return this.save(); + } + + public getDocumentByDocumentId( + target: DocumentId + ): { path: RelativePath; record: DocumentRecord } | undefined { + for (const [path, record] of this.documents) { + if (record.documentId === target) { + return { path, record }; } } - - this.saveInTheBackground(); + return undefined; } + + + public getDocumentByDocumentIdOrFail( + target: DocumentId + ): { path: RelativePath; record: DocumentRecord } { + const result = this.getDocumentByDocumentId(target); + if (!result) { + throw new Error(`No document found with id ${target}`); + } + return result; + } + + + + + public async save(): Promise { return this.saveData({ documents: Array.from(this.documents.entries()).map( @@ -186,35 +215,9 @@ export class SyncEventQueue { return this.documents.get(path); } - public getDocumentByDocumentId( - target: DocumentId - ): { path: RelativePath; record: DocumentRecord } | undefined { - for (const [path, record] of this.documents) { - if (record.documentId === target) { - return { path, record }; - } - } - return undefined; - } - - public setDocument(path: RelativePath, record: DocumentRecord): void { - this.documents.set(path, record); - this.saveInTheBackground(); - } - - public removeDocument(path: RelativePath): void { - this.documents.delete(path); - this.saveInTheBackground(); - } - public getLatestCreatePromise(path: RelativePath): Promise | undefined { - const event = this.findLatestCreate(path); - if (event === undefined) return undefined; - event.resolvers ??= Promise.withResolvers(); - return event.resolvers.promise; - } public allSettledDocuments(): [RelativePath, DocumentRecord][] { return Array.from(this.documents.entries()); @@ -257,7 +260,7 @@ export class SyncEventQueue { public hasPendingEventsForPath(path: RelativePath): boolean { const record = this.documents.get(path); - if (!record) { + if (record === undefined) { return true; // if we don't know about this path, it must be pending creation } const docId = record.documentId; @@ -268,12 +271,22 @@ export class SyncEventQueue { e.documentId === docId) || (e.type === SyncEventType.LocalDelete && e.documentId === docId) || - (e.type === SyncEventType.RemoteUpdate && + (e.type === SyncEventType.RemoteChange && // we care about the local path not the remote this.getDocumentByDocumentId(e.remoteVersion.documentId)?.path === path) ); } + public hasPendingLocalEventsForDocumentId(documentId: DocumentId): boolean { + return this.events.some( + (e) => + (e.type === SyncEventType.LocalUpdate && + e.documentId === documentId) || + (e.type === SyncEventType.LocalDelete && + e.documentId === documentId) + ); + } + public resetState(): void { this.rejectAllPendingCreates(); @@ -288,18 +301,13 @@ export class SyncEventQueue { - - private isIgnored(path: RelativePath): boolean { - return this.ignorePatterns.some((pattern) => pattern.test(path)); - } - public removeAllEventsForDocumentId(documentId: DocumentId): void { for (let i = this.events.length - 1; i >= 0; i--) { const e = this.events[i]; if ( (e.type === SyncEventType.LocalUpdate && e.documentId === documentId) || - (e.type === SyncEventType.RemoteUpdate && + (e.type === SyncEventType.RemoteChange && e.remoteVersion.documentId === documentId) || (e.type === SyncEventType.LocalDelete && e.documentId === documentId) @@ -310,11 +318,11 @@ export class SyncEventQueue { } } - public updatePendingCreatePath( + private updatePendingCreatePath( oldPath: RelativePath, newPath: RelativePath ): void { - const createEvent = this.findLatestCreate(oldPath); + const createEvent = this.findLatestCreateForPath(oldPath); if (createEvent === undefined) return; const promise = createEvent.resolvers?.promise; @@ -332,7 +340,7 @@ export class SyncEventQueue { } } - private findLatestCreate( + public findLatestCreateForPath( path: RelativePath ): Extract | undefined { for (let i = this.events.length - 1; i >= 0; i--) { @@ -344,40 +352,9 @@ export class SyncEventQueue { return undefined; } - /** - * Returns whether there is an unsynced Create event queued at `path`. - * A caller uses this to decide between displacing the local file vs. - * merging it with a concurrent remote create. - */ - public hasPendingCreateAt(path: RelativePath): boolean { - return this.findLatestCreate(path) !== undefined; - } - /** - * Cancel the latest queued Create for `path`. Rejects its resolver - * promise (so any dependent SyncLocal/Delete events that `await`ed - * the future documentId skip themselves gracefully) and removes the - * Create event from the queue. Returns true if a Create was found - * and cancelled. - */ - public cancelPendingCreate(path: RelativePath): boolean { - const event = this.findLatestCreate(path); - if (event === undefined) return false; - if (event.resolvers !== undefined) { - event.resolvers.promise.catch(() => { - /* suppressed — consumer may not be listening */ - }); - event.resolvers.reject( - new Error( - "Create was cancelled — merged with concurrent remote create" - ) - ); - } - removeFromArray(this.events, event); - return true; - } private rejectAllPendingCreates(): void { for (const event of this.events) { diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 6e123edc..3dbcd6cf 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -46,7 +46,8 @@ export class Syncer { private _isFirstSyncStarted = false; private runningScheduleSyncForOfflineChanges: Promise | undefined; - private draining: Promise | undefined; + private drainPromise: Promise | undefined; + private isScanning = false; private previousRemainingOperationsCount = 0; public constructor( @@ -101,15 +102,13 @@ export class Syncer { this.ensureDraining(); } - - public async syncRemotelyUpdatedFile( message: WebSocketVaultUpdate ): Promise { await this.scheduleSyncForOfflineChanges(); this.queue.enqueue({ - type: SyncEventType.RemoteUpdate, + type: SyncEventType.RemoteChange, remoteVersion: message.document }); @@ -145,13 +144,10 @@ export class Syncer { public async waitUntilFinished(): Promise { await this.runningScheduleSyncForOfflineChanges; - // Loop until the draining promise stabilises — new drains can be - // chained by events enqueued during processing - let current = this.draining; - while (current !== undefined) { - await current; - if (this.draining === current) break; - current = this.draining; + // A drain that finishes can be immediately followed by a new one + // (e.g. a remote event arriving), so re-check after each await. + while (this.drainPromise !== undefined) { + await this.drainPromise; } } @@ -176,9 +172,6 @@ export class Syncer { } }); } - // Do not set this.draining = undefined — the in-flight drain will - // exit naturally (SyncResetError or empty queue) and the promise - // chain stays intact, preventing concurrent drain invocations } @@ -199,45 +192,36 @@ export class Syncer { // Offline scan wipes the event queue via `queue.clear()` and then // rebuilds events from disk. That MUST NOT race against an // in-flight drain iteration that may already hold a reference to - // a freshly-cleared event — chain onto the drain so the scan runs - // between drain ticks, never concurrently. - await this.chainOntoDrain(async () => { + // a freshly-cleared event — wait for any drain to finish, and + // suppress new drains for the duration of the scan. + this.isScanning = true; + try { + while (this.drainPromise !== undefined) { + await this.drainPromise; + } await scheduleOfflineChanges( { logger: this.logger, operations: this.operations, queue: this.queue }, (path) => { this.syncLocallyCreatedFile(path); }, (args) => { this.syncLocallyUpdatedFile(args); }, (path) => { this.syncLocallyDeletedFile(path); }, ); - }); + } finally { + this.isScanning = false; + } this.ensureDraining(); - await this.draining; + await this.drainPromise; } - /** - * Serialize a unit of work onto the same promise chain the drain - * uses. This is how direct WebSocket handlers (`syncRemotelyChangedPath`, - * offline-scan) avoid racing against the drain loop: every mutator of - * the queue / disk goes through this single chain, in order of arrival. - */ - private async chainOntoDrain(work: () => Promise): Promise { - const chained = (this.draining ?? Promise.resolve()).then( - async () => work() - ); - // We track the chain via `this.draining` so later work chains onto - // the latest link. Swallow the result-typed value for storage; the - // caller still awaits the true result via `chained`. - this.draining = chained.then( - () => undefined, - () => undefined - ); - return chained; - } private ensureDraining(): void { - void this.chainOntoDrain(async () => this.drain()); + if (this.drainPromise !== undefined) return; + if (this.isScanning) return; + this.drainPromise = this.drain().finally(() => { + this.drainPromise = undefined; + }); } @@ -269,6 +253,10 @@ export class Syncer { } try { + if (await this.skipIfOversized(event)) { + return; + } + switch (event.type) { case SyncEventType.LocalCreate: await this.processCreate(event); @@ -277,10 +265,10 @@ export class Syncer { await this.processDelete(event); break; case SyncEventType.LocalUpdate: - await this.processSyncLocal(event); + await this.processLocalUpdate(event); break; - case SyncEventType.RemoteUpdate: - await this.processSyncRemoteContent(event); + case SyncEventType.RemoteChange: + await this.processRemoteChange(event); break; } } catch (e) { @@ -319,6 +307,61 @@ export class Syncer { } + private async skipIfOversized(event: SyncEvent): Promise { + let sizeInBytes: number; + let relativePath: RelativePath; + + switch (event.type) { + case SyncEventType.LocalDelete: + return false; + case SyncEventType.LocalCreate: + case SyncEventType.LocalUpdate: + sizeInBytes = await this.operations.getFileSize(event.path); + relativePath = event.path; + break; + case SyncEventType.RemoteChange: + if (event.remoteVersion.isDeleted) return false; + sizeInBytes = event.remoteVersion.contentSize; + relativePath = event.remoteVersion.relativePath; + break; + } + + const oversizedEntry = this.getHistoryEntryForSkippedOversizedFile( + sizeInBytes, + relativePath + ); + if (oversizedEntry === undefined) return false; + + this.history.addHistoryEntry(oversizedEntry); + + if (event.type === SyncEventType.LocalCreate) { + event.resolvers?.promise.catch(() => { }); + event.resolvers?.reject(new Error("Create was cancelled")); + } + + return true; + } + + private getHistoryEntryForSkippedOversizedFile( + sizeInBytes: number, + relativePath: RelativePath + ): CommonHistoryEntry | undefined { + const sizeInMB = Math.round(sizeInBytes / 1024 / 1024); + const { maxFileSizeMB } = this.settings.getSettings(); + if (sizeInMB > maxFileSizeMB) { + return { + status: SyncStatus.SKIPPED, + details: { + type: SyncType.SKIPPED as const, + relativePath + }, + message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${maxFileSizeMB} MB` + }; + } + } + + + private async processCreate( event: Extract @@ -327,44 +370,12 @@ export class Syncer { const contentBytes = await this.operations.read(effectivePath); const contentHash = await hash(contentBytes); - const oversizedEntry = this.getHistoryEntryForSkippedOversizedFile( - contentBytes.byteLength, - effectivePath - ); - if (oversizedEntry !== undefined) { - this.history.addHistoryEntry(oversizedEntry); - event.resolvers?.promise.catch(() => { }); - event.resolvers?.reject(new Error("Create was cancelled")); - return; - } - const response = await this.syncService.create({ relativePath: event.originalPath, lastSeenVaultUpdateId: this.queue.lastSeenUpdateId, contentBytes }); - - // Handle concurrent move & creation: the server merged our create - // with an existing document that we also have locally at a different path - const existingDoc = this.queue.getDocumentByDocumentId( - response.documentId - ); - - // need to merge in db - if (existingDoc !== undefined && existingDoc.path !== effectivePath) { - // this.logger.info( - // `Merging existing document ${existingDoc.path} into ${effectivePath} after concurrent move & creation` - // ); - // await this.operations.delete(existingDoc.path); - // this.queue.removeDocument(existingDoc.path); - // if (!this.queue.getDocumentByDocumentId(existingDoc.record.documentId)) { - // this.queue.removeAllEventsForDocumentId(existingDoc.record.documentId); - // } - // } - } - - await this.handleMaybeMergingResponse({ path: effectivePath, response, @@ -387,27 +398,9 @@ export class Syncer { private async processDelete( event: Extract ): Promise { - let documentId: DocumentId; - if (typeof event.documentId === "string") { - documentId = event.documentId; - } else { - try { - documentId = await event.documentId; - } catch { - this.logger.debug( - "Skipping delete for a document whose create was cancelled" - ); - return; - } - } + let documentId = await event.documentId; - const doc = this.queue.getDocumentByDocumentId(documentId); - if (doc === undefined) { - this.logger.debug( - `Skipping delete for unknown documentId ${documentId}` - ); - return; - } + const doc = this.queue.getDocumentByDocumentIdOrFail(documentId); const relativePath = doc.path; const response = await this.syncService.delete({ @@ -415,7 +408,7 @@ export class Syncer { relativePath }); - this.queue.removeDocument(doc.path); + await this.queue.removeDocument(doc.path); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -428,56 +421,32 @@ export class Syncer { }); } - private async processSyncLocal( + private async processLocalUpdate( event: Extract ): Promise { - let documentId: DocumentId; - if (typeof event.documentId === "string") { - documentId = event.documentId; - } else { - try { - documentId = await event.documentId; - } catch { - this.logger.debug( - "Skipping sync-local for a document whose create was cancelled" - ); - return; - } - } + let documentId = await event.documentId; - const doc = this.queue.getDocumentByDocumentId(documentId); + const { path: diskPath, record } = this.queue.getDocumentByDocumentIdOrFail(documentId); - if (doc === undefined) { - this.logger.debug( - `Skipping sync-local for unknown document ${documentId}` - ); - return; - } - - const { path: diskPath, record } = doc; - - // Read file from the current disk path const contentBytes = await this.operations.read(diskPath); const contentHash = await hash(contentBytes); - // Upload using the original path - const uploadPath = event.originalPath; - + const hashChanged = contentHash !== record.remoteHash; const pathChanged = - record.remoteRelativePath !== undefined && - record.remoteRelativePath !== uploadPath; + record.remoteRelativePath !== event.originalPath; - if (contentHash === record.remoteHash && !pathChanged) { + if (!hashChanged && !pathChanged) { this.logger.debug( `File hash of ${diskPath} matches last synced version; no need to sync` ); return; } - const response = await this.sendUpdate( + const response = await this.sendUpdate({ record, - uploadPath, + relativePath: event.originalPath, contentBytes + } ); await this.handleMaybeMergingResponse({ @@ -504,274 +473,181 @@ export class Syncer { }); } - private async processSyncRemoteContent( - event: Extract - ): Promise { - const { remoteVersion } = event; - const existingDoc = this.queue.getDocumentByDocumentId( - remoteVersion.documentId - ); - - if (existingDoc !== undefined) { - if ( - existingDoc.record.parentVersionId >= - remoteVersion.vaultUpdateId - ) { - this.logger.debug( - `Document ${existingDoc.path} is already up-to-date` - ); - return; - } - - await this.processRemoteUpdateForExistingDocument( - existingDoc.path, - existingDoc.record, - remoteVersion - ); - return; - } - - if (remoteVersion.isDeleted) { - this.logger.debug( - `Document ${remoteVersion.relativePath} has been deleted remotely, no need to sync` - ); - return; - } - - await this.processRemoteUpdateForNewDocument(remoteVersion); - } - - private async processRemoteUpdateForExistingDocument( - currentPath: RelativePath, - record: DocumentRecord, - remoteVersion: DocumentVersionWithoutContent - ): Promise { - if (remoteVersion.isDeleted) { - // Check for local changes before deleting - let hasLocalChanges = false; - try { - const contentBytes = await this.operations.read(currentPath); - const contentHash = await hash(contentBytes); - hasLocalChanges = record.remoteHash !== contentHash; - } catch (e) { - if (!(e instanceof FileNotFoundError)) throw e; - } - - if (hasLocalChanges) { - // Local changes survive; re-upload as a new document - this.queue.removeDocument(currentPath); - this.syncLocallyCreatedFile(currentPath); - return; - } - - await this.operations.delete(currentPath); - this.queue.removeDocument(currentPath); - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.DELETE, - relativePath: currentPath - }, - message: - "Successfully deleted file which had been deleted remotely", - author: remoteVersion.userId, - timestamp: new Date(remoteVersion.updatedDate) - }); - return; - } - - // Fetch the latest full version from the server - const fullVersion = await this.syncService.get({ - documentId: remoteVersion.documentId - }); - - // The document may have been deleted between the broadcast - // and the fetch — handle it the same as a remote delete - if (fullVersion.isDeleted) { - const contentBytes = await this.operations.read(currentPath); - const localHash = await hash(contentBytes); - if (localHash !== record.remoteHash) { - this.queue.removeDocument(currentPath); - this.syncLocallyCreatedFile(currentPath); - } else { - await this.operations.delete(currentPath); - this.queue.removeDocument(currentPath); - } - return; - } - - const contentBytes = await this.operations.read(currentPath); - const contentHash = await hash(contentBytes); - - const hasLocalChanges = record.remoteHash !== contentHash; - - if (hasLocalChanges) { - const response = await this.sendUpdate( - record, - currentPath, - contentBytes - ); - - await this.handleMaybeMergingResponse({ - path: currentPath, - response, - contentHash, - originalContentBytes: contentBytes - }); - - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.UPDATE, - relativePath: currentPath - }, - message: "Merged local changes with remote update", - author: response.userId, - timestamp: new Date(response.updatedDate) - }); - } else { - const responseBytes = base64ToBytes(fullVersion.contentBase64); - - // Path reconciliation fallback for the reconnect case. - // - // In steady-state streaming, server-initiated renames arrive - // as `VaultUpdate` events with `originatesFromSelf=true` for - // the author and drive `processSyncRemotePath`. The reconnect - // catch-up (`get_unseen_documents` → `is_initial_sync=true`) - // replays versions authored by any device with - // `originatesFromSelf=false`, so those take the full remote- - // sync branch and we need this in-branch path reconciliation - // to avoid leaving the local file stuck at its old path. - // - // Only apply the server's path when the record's - // `remoteRelativePath` still matches `currentPath` — that means - // we haven't locally renamed since we last heard from the - // server, so the server's path is authoritative. Any local - // rename in flight keeps priority (it'll be resolved by the - // server on its next write). - let targetPath = currentPath; - if ( - fullVersion.relativePath !== currentPath && - record.remoteRelativePath === currentPath - ) { - await this.operations.move(currentPath, fullVersion.relativePath); - targetPath = fullVersion.relativePath; - } + private async handleMaybeMergingResponse({ + path, + response, + contentHash, + originalContentBytes, + createEvent + }: { + path: RelativePath; + response: DocumentUpdateResponse; + contentHash: string; + originalContentBytes: Uint8Array; + // When processing a Create, pass the originating event so its + // `resolvers` promise can be fulfilled (or rejected, on a deleted + // response) + createEvent?: Extract; + }): Promise { + let record = { + documentId: response.documentId, + parentVersionId: response.vaultUpdateId, + remoteRelativePath: response.relativePath + }; + let remoteHash: string; + if ("type" in response && response.type === "MergingUpdate") { + const responseBytes = base64ToBytes(response.contentBase64); await this.operations.write( - targetPath, - contentBytes, + path, + originalContentBytes, responseBytes ); - // Re-read and re-hash after write (the 3-way merge may produce different content) - const afterWriteBytes = await this.operations.read(targetPath); - const afterWriteHash = await hash(afterWriteBytes); - - if (targetPath !== currentPath) { - this.queue.removeDocument(currentPath); - } - this.queue.setDocument(targetPath, { - documentId: fullVersion.documentId, - parentVersionId: fullVersion.vaultUpdateId, - remoteHash: afterWriteHash, - remoteRelativePath: fullVersion.relativePath - }); + remoteHash = await hash(responseBytes); await this.updateCache( - fullVersion.vaultUpdateId, + response.vaultUpdateId, responseBytes, - targetPath + path ); + } else { + // Fast-forward update: no merge needed + remoteHash = contentHash; - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: - targetPath !== currentPath - ? { - type: SyncType.MOVE, - relativePath: targetPath, - movedFrom: currentPath - } - : { - type: SyncType.UPDATE, - relativePath: targetPath - }, - message: - "Successfully downloaded remotely updated file from the server", - author: fullVersion.userId, - timestamp: new Date(fullVersion.updatedDate) + await this.updateCache( + response.vaultUpdateId, + originalContentBytes, + path + ); + } + + if (createEvent === undefined) { + this.ensurePath(path, response.relativePath, Move.Existing); + await this.queue.setDocument(response.relativePath, { + ...record, + remoteHash + }); + + } else { + // The response to a create must contain the path from the create request + await this.queue.resolveCreate(createEvent, { + ...record, + remoteHash }); } } - private async processRemoteUpdateForNewDocument( - remoteVersion: DocumentVersionWithoutContent + + private async processRemoteChange( + event: Extract ): Promise { - const oversizedEntry = this.getHistoryEntryForSkippedOversizedFile( - remoteVersion.contentSize, - remoteVersion.relativePath - ); - if (oversizedEntry !== undefined) { - this.history.addHistoryEntry(oversizedEntry); - return; - } - - const contentBytes = - await this.syncService.getDocumentVersionContent({ - documentId: remoteVersion.documentId, - vaultUpdateId: remoteVersion.vaultUpdateId - }); - - // A concurrent operation may have created the document already - const existingDoc = this.queue.getDocumentByDocumentId( + const { remoteVersion } = event; + const documentWithPath = this.queue.getDocumentByDocumentId( remoteVersion.documentId ); - if (existingDoc !== undefined) { + + if (remoteVersion.isDeleted) { + if (documentWithPath === undefined) { + // trying to delete a document we've already scheduled for deletion locally + return; + } + return this.processRemoteDelete(documentWithPath.path, remoteVersion); + } + + + + if (documentWithPath !== undefined) { + // must be the update to an existing doc + return this.processRemoteUpdate(documentWithPath.path, documentWithPath.record, remoteVersion); + } + + const pendingCreate = this.queue.findLatestCreateForPath(remoteVersion.relativePath); + + if (pendingCreate === undefined) { + return this.processRemoteCreateForNewDocument(remoteVersion); + } else { + return this.processRemoteCreateForPendingDocument(remoteVersion, pendingCreate); + } + } + + + private async processRemoteDelete(path: RelativePath, remoteVersion: DocumentVersionWithoutContent): Promise { + await this.operations.delete(path); + await this.queue.removeDocument(path); + + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: { + type: SyncType.DELETE, + relativePath: path + }, + message: + "Successfully deleted file which had been deleted remotely", + author: remoteVersion.userId, + timestamp: new Date(remoteVersion.updatedDate) + }); + } + + private async processRemoteUpdate(path: RelativePath, record: DocumentRecord, remoteVersion: DocumentVersionWithoutContent): Promise { + if ( + record.parentVersionId >= + remoteVersion.vaultUpdateId + ) { this.logger.debug( - `Document ${remoteVersion.relativePath} has already been created locally` + `Document ${path} is already up-to-date` ); return; } - // Special case: local has an *unsynced* new file at the same path. - // The client must cancel the outgoing Create and merge the two files - // instead of displacing the local one to a conflict path — those - // files are semantically "the same user-intended document" that two - // devices created concurrently, so we want to preserve both sides' - // edits, not shelve one aside. - if (this.queue.hasPendingCreateAt(remoteVersion.relativePath)) { - await this.mergeUnsyncedLocalWithRemoteCreate( - remoteVersion, - contentBytes - ); - return; - } + if (!this.queue.hasPendingLocalEventsForDocumentId(remoteVersion.documentId)) { + // no local changes + const currentContent = await this.operations.read(path); + const remoteContent = await this.syncService.getDocumentVersionContent({ documentId: remoteVersion.documentId, vaultUpdateId: remoteVersion.vaultUpdateId }); + this.operations.write(path, currentContent, remoteContent); + // todo: update last seen id - await this.operations.ensureClearPath(remoteVersion.relativePath); + } // else we don't need to update the content, a subsequent local update will do that - const contentHash = await hash(contentBytes); - this.queue.setDocument(remoteVersion.relativePath, { + this.ensurePath(path, remoteVersion.relativePath); + + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: { + type: SyncType.MOVE, + relativePath: remoteVersion.relativePath, + movedFrom: path + }, + // todo: eh + message: `File was renamed remotely from ${path} to ${remoteVersion.relativePath}`, + }); + } + + private async processRemoteCreateForNewDocument(remoteVersion: DocumentVersionWithoutContent): Promise { + const remoteContent = await this.syncService.getDocumentVersionContent({ + documentId: remoteVersion.documentId, + vaultUpdateId: remoteVersion.vaultUpdateId + }); + + await this.operations.create( + remoteVersion.relativePath, + remoteContent + ); + + await this.updateCache( + remoteVersion.vaultUpdateId, + remoteContent, + remoteVersion.relativePath + ); + + const contentHash = await hash(remoteContent); + await this.queue.setDocument(remoteVersion.relativePath, { documentId: remoteVersion.documentId, parentVersionId: remoteVersion.vaultUpdateId, remoteHash: contentHash, remoteRelativePath: remoteVersion.relativePath }); - await this.operations.create( - remoteVersion.relativePath, - contentBytes - ); - - await this.updateCache( - remoteVersion.vaultUpdateId, - contentBytes, - remoteVersion.relativePath - ); - - this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { @@ -786,13 +662,18 @@ export class Syncer { } // A remote create landed at a path where we have an unsynced local - // create. How we resolve depends on whether both sides are mergeable - // text: text gets an in-place union merge and one follow-up update; - // binary falls through to displacement so *both* files survive. - private async mergeUnsyncedLocalWithRemoteCreate( + // create. This might be becuase there's another sync client running. + // We must avoid duplicating files. + private async processRemoteCreateForPendingDocument( remoteVersion: DocumentVersionWithoutContent, - remoteContent: Uint8Array + pendingCreateEvent: Extract ): Promise { + const remoteContent = await this.syncService.getDocumentVersionContent({ + documentId: remoteVersion.documentId, + vaultUpdateId: remoteVersion.vaultUpdateId + }); + const remoteHash = await hash(remoteContent); + const path = remoteVersion.relativePath; const localContent = await this.operations.read(path); @@ -804,33 +685,19 @@ export class Syncer { !isBinary(localContent) && !isBinary(remoteContent); - if (!canMergeText) { - // Binary (or non-mergeable) concurrent creates: leave the local - // Create in the queue and let the default displacement flow - // take over (local bytes are moved to `conflict--…` by - // `ensureClearPath`, remote bytes take `path`). When the Create - // eventually fires it reads the remote content at `path` — not - // what we want — so cancel *just* the Create event and - // re-enqueue a fresh one sourced from the displaced path, so - // the server receives the user's original bytes and dedupes - // the path on its own. - this.queue.cancelPendingCreate(path); + if (canMergeText) { + const currentContent = await this.operations.read(pendingCreateEvent.path); - // `ensureClearPath` may return `undefined` if the file was - // deleted between `read(path)` above and this call (a TOCTOU - // race with a concurrent filesystem delete). That's fine: - // nothing to displace means no local bytes to preserve, and - // we just proceed with the remote content. - const conflictPath = - await this.operations.ensureClearPath(path); - - this.queue.setDocument(path, { + this.queue.resolveCreate(pendingCreateEvent, { documentId: remoteVersion.documentId, parentVersionId: remoteVersion.vaultUpdateId, - remoteHash: await hash(remoteContent), + remoteHash, remoteRelativePath: path }); - await this.operations.create(path, remoteContent); + + + const merged = reconcile("", new TextDecoder().decode(currentContent), new TextDecoder().decode(remoteContent)).text; + await this.operations.write(path, currentContent, new TextEncoder().encode(merged)); await this.updateCache( remoteVersion.vaultUpdateId, remoteContent, @@ -840,82 +707,48 @@ export class Syncer { this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { - type: SyncType.CREATE, + type: SyncType.UPDATE, relativePath: path }, message: - conflictPath !== undefined - ? `Adopted remote create at ${path}; unsynced local bytes preserved at ${conflictPath} for manual recovery` - : `Adopted remote create at ${path}; local file had already been removed`, + `Adopted remote create at ${path}`, author: remoteVersion.userId, timestamp: new Date(remoteVersion.updatedDate) }); return; + } else { + await this.operations.ensureClearPath(path); + await this.operations.create(path, remoteContent); + await this.queue.setDocument(path, { + documentId: remoteVersion.documentId, + parentVersionId: remoteVersion.vaultUpdateId, + remoteHash, + remoteRelativePath: path + }); + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: { + type: SyncType.CREATE, + relativePath: path + }, + message: + `Created remotly created file at ${path}`, + author: remoteVersion.userId, + timestamp: new Date(remoteVersion.updatedDate) + }); } - - // Mergeable text: union-merge with empty parent (every byte in - // either side is treated as an insertion), overwrite disk, and - // push the merged result to the server if it diverged from the - // remote copy. Cancelling the Create and re-emitting as a - // SyncLocal update lets the existing merge-response pipeline - // handle parentVersionId/content reconciliation end-to-end. - this.queue.cancelPendingCreate(path); - - const mergedContent = new TextEncoder().encode( - reconcile( - "", - new TextDecoder().decode(localContent), - new TextDecoder().decode(remoteContent) - ).text - ); - - // Adopt the remote document's identity locally *before* touching - // disk so an interleaved event can't mistake the file for a fresh - // create again. `remoteHash` is deliberately the server's content - // hash (not the merged one) so the SyncLocal below sees a real - // diff and actually uploads the merge. - const remoteHash = await hash(remoteContent); - this.queue.setDocument(path, { - documentId: remoteVersion.documentId, - parentVersionId: remoteVersion.vaultUpdateId, - remoteHash, - remoteRelativePath: path - }); - - // Overwrite disk with the merged result. We pass `localContent` as - // the "expected" content so `operations.write`'s internal 3-way - // merge is a no-op (expected == disk ⇒ apply `new` verbatim). - await this.operations.write(path, localContent, mergedContent); - - await this.updateCache( - remoteVersion.vaultUpdateId, - remoteContent, - path - ); - - const mergedHash = await hash(mergedContent); - if (mergedHash !== remoteHash) { - this.syncLocallyUpdatedFile({ relativePath: path }); - } - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.CREATE, - relativePath: path - }, - message: "Merged unsynced local file with concurrent remote create", - author: remoteVersion.userId, - timestamp: new Date(remoteVersion.updatedDate) - }); } + + private async sendUpdate( - record: DocumentRecord, - relativePath: RelativePath, - contentBytes: Uint8Array + { record, relativePath, contentBytes }: { + record: DocumentRecord, + relativePath: RelativePath, + contentBytes: Uint8Array + } ): Promise { const isText = !isBinary(contentBytes) && @@ -946,145 +779,7 @@ export class Syncer { }); } - private async handleMaybeMergingResponse({ - path, - response, - contentHash, - originalContentBytes, - createEvent - }: { - path: RelativePath; - response: DocumentUpdateResponse; - contentHash: string; - originalContentBytes: Uint8Array; - // When processing a Create, pass the originating event so its - // `resolvers` promise can be fulfilled (or rejected, on a deleted - // response). Dependent SyncLocal/Delete events are chained through - // that promise and would otherwise `await` forever. - createEvent?: Extract; - }): Promise { - if (response.isDeleted) { - // A Create that the server returned as already-deleted means - // nothing we can sync — reject the waiting promise so chained - // Delete / SyncLocal events skip themselves instead of hanging. - if (createEvent?.resolvers !== undefined) { - createEvent.resolvers.promise.catch(() => { - /* suppressed — consumer may not be listening */ - }); - createEvent.resolvers.reject( - new Error( - "Create was cancelled — server reported the document as deleted" - ) - ); - } - // Capture the documentId of the record we *believe* is at - // `path` now. If a concurrent `syncRemotelyChangedPath` moves - // this document between our exists-check and our read, the - // record at `path` after those awaits may belong to a - // DIFFERENT document. Guard against that. - const originalRecord = - this.queue.getSettledDocumentByPath(path); - const originalDocumentId = originalRecord?.documentId; - - // If the local file has been edited, re-create it as a new - // document so local edits survive the remote delete — but only - // if nothing else is already queuing a Create for this path, to - // avoid doubling up when offline-change detection races with us. - if (await this.operations.exists(path)) { - const localBytes = await this.operations.read(path); - const localHash = await hash(localBytes); - const currentRecord = - this.queue.getSettledDocumentByPath(path); - // Re-verify the record's identity hasn't shifted under us. - if ( - currentRecord !== undefined && - currentRecord.documentId === originalDocumentId && - localHash !== currentRecord.remoteHash && - !this.queue.hasPendingCreateAt(path) - ) { - this.queue.removeDocument(path); - this.syncLocallyCreatedFile(path); - return; - } - } - // Only delete on disk if the record at `path` is still the one - // we expected — if a self-origin path-change moved another doc - // here, we shouldn't delete its file. - const finalRecord = this.queue.getSettledDocumentByPath(path); - if ( - finalRecord === undefined || - finalRecord.documentId === originalDocumentId - ) { - await this.operations.delete(path); - this.queue.removeDocument(path); - } - return; - } - - // The response carries content only — path reconciliation is the - // sole responsibility of the self-origin `VaultUpdate` echo (the - // `originatesFromSelf=true` branch of `syncRemoteVaultUpdate`), - // which fires independently for renames/dedupes. We therefore - // always record the current local `path` here; an in-flight echo - // will move the file and fix `remoteRelativePath` if the server - // placed the document somewhere else. - const existingRecord = this.queue.getSettledDocumentByPath(path); - const remoteRelativePath = existingRecord?.remoteRelativePath ?? path; - - let record: DocumentRecord; - if ("type" in response && response.type === "MergingUpdate") { - const responseBytes = base64ToBytes(response.contentBase64); - await this.operations.write( - path, - originalContentBytes, - responseBytes - ); - - // Re-read and re-hash after write (invariant #3) - const afterWriteBytes = await this.operations.read(path); - const afterWriteHash = await hash(afterWriteBytes); - - record = { - documentId: response.documentId, - parentVersionId: response.vaultUpdateId, - remoteHash: afterWriteHash, - remoteRelativePath - }; - - // Cache the SERVER's content, not local (invariant #2) - await this.updateCache( - response.vaultUpdateId, - responseBytes, - path - ); - } else { - // Fast-forward update: no merge needed - record = { - documentId: response.documentId, - parentVersionId: response.vaultUpdateId, - remoteHash: contentHash, - remoteRelativePath - }; - - await this.updateCache( - response.vaultUpdateId, - originalContentBytes, - path - ); - } - - // For a Create, fulfill the resolver promise and replace any - // `documentId: Promise<...>` references in queued Delete/SyncLocal - // events with the now-known string id. For everything else a plain - // `setDocument` is enough — the record's identity was already - // resolved when the Create originally settled. - if (createEvent !== undefined) { - this.queue.resolveCreate(createEvent, record); - } else { - this.queue.setDocument(path, record); - } - } private async updateCache( updateId: VaultUpdateId, @@ -1102,24 +797,6 @@ export class Syncer { } } - private getHistoryEntryForSkippedOversizedFile( - sizeInBytes: number, - relativePath: RelativePath - ): CommonHistoryEntry | undefined { - const sizeInMB = Math.round(sizeInBytes / 1024 / 1024); - const { maxFileSizeMB } = this.settings.getSettings(); - if (sizeInMB > maxFileSizeMB) { - return { - status: SyncStatus.SKIPPED, - details: { - type: SyncType.SKIPPED as const, - relativePath - }, - message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${maxFileSizeMB} MB` - }; - } - } - private notifyRemainingOperationsChanged(): void { const currentCount = this.queue.pendingUpdateCount; if (this.previousRemainingOperationsCount !== currentCount) { diff --git a/frontend/sync-client/src/sync-operations/types.ts b/frontend/sync-client/src/sync-operations/types.ts index 57cd8a6f..9642665a 100644 --- a/frontend/sync-client/src/sync-operations/types.ts +++ b/frontend/sync-client/src/sync-operations/types.ts @@ -8,7 +8,7 @@ export interface DocumentRecord { documentId: DocumentId; parentVersionId: VaultUpdateId; remoteHash: string; - remoteRelativePath?: RelativePath; + remoteRelativePath: RelativePath; } export interface StoredDocument extends DocumentRecord { @@ -24,21 +24,21 @@ export enum SyncEventType { LocalCreate = "local-create", LocalUpdate = "local-update", // includes both content and path changes LocalDelete = "local-delete", - RemoteUpdate = "remote-update", // includes every type of update coming from the server + RemoteChange = "remote-change", // includes every type of create/update/delete coming from the server } export type FileSyncEvent = | { type: SyncEventType.LocalCreate; path: RelativePath } | { type: SyncEventType.LocalUpdate; path: RelativePath; oldPath?: RelativePath } | { type: SyncEventType.LocalDelete; path: RelativePath } - | { type: SyncEventType.RemoteUpdate; remoteVersion: DocumentVersionWithoutContent }; + | { type: SyncEventType.RemoteChange; remoteVersion: DocumentVersionWithoutContent }; export type SyncEvent = | { type: SyncEventType.LocalCreate; path: RelativePath; // current path on disk originalPath: RelativePath; // original path on disk when the event was queued - resolvers?: PromiseWithResolvers + resolvers: PromiseWithResolvers } | { type: SyncEventType.LocalUpdate; @@ -52,6 +52,6 @@ export type SyncEvent = documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed } | { - type: SyncEventType.RemoteUpdate; + type: SyncEventType.RemoteChange; remoteVersion: DocumentVersionWithoutContent; }; From b52c09fecc7b1e9a34a9c74f48f1bb449f6cb0e3 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 13:40:34 +0100 Subject: [PATCH 14/52] Small changes --- frontend/sync-client/src/sync-client.ts | 2 +- .../src/sync-operations/conflict-path.ts | 10 +- .../offline-change-detector.ts | 250 +++--------------- .../sync-operations/sync-event-queue.test.ts | 4 +- .../src/sync-operations/sync-event-queue.ts | 90 ++----- .../sync-client/src/sync-operations/syncer.ts | 2 +- .../sync-client/src/sync-operations/types.ts | 9 +- .../src/utils/find-matching-file.ts | 4 +- 8 files changed, 74 insertions(+), 297 deletions(-) diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 39b0f000..65580420 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -324,7 +324,7 @@ export class SyncClient { await this.pause(); this.logger.info("Resetting SyncClient's local state"); - this.syncEventQueue.resetState(); + this.syncEventQueue.clearAllState(); await this.syncEventQueue.save(); this.resetInMemoryState(); this.hasFinishedOfflineSync = false; diff --git a/frontend/sync-client/src/sync-operations/conflict-path.ts b/frontend/sync-client/src/sync-operations/conflict-path.ts index 7a634bb4..69942750 100644 --- a/frontend/sync-client/src/sync-operations/conflict-path.ts +++ b/frontend/sync-client/src/sync-operations/conflict-path.ts @@ -8,18 +8,12 @@ export const CONFLICT_PATH_REGEX = /(?:^|\/)conflict-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-[^/]*$/u; -// Safe segment length for common filesystems (ext4 / NTFS / APFS all cap -// at 255 bytes). `conflict-<36-char-uuid>-` adds 46 bytes; reserve a few -// extra bytes for a future prefix bump and leave room for multi-byte UTF-8 -// characters in the original name. + const CONFLICT_PREFIX_LEN = "conflict-".length + 36 + 1; const MAX_SEGMENT_BYTES = 255; const MAX_ORIGINAL_BYTES = MAX_SEGMENT_BYTES - CONFLICT_PREFIX_LEN - 4; export function buildConflictFileName(fileName: string): string { - // Truncate the original name if keeping it whole would bust the - // filesystem's segment-length cap. Preserve the trailing extension - // so the file is still recognizable / openable. const safeName = truncateFileNameToByteLimit(fileName, MAX_ORIGINAL_BYTES); return `conflict-${crypto.randomUUID()}-${safeName}`; } @@ -40,8 +34,6 @@ function truncateFileNameToByteLimit( const extensionBytes = encoder.encode(extension).byteLength; const stemBudget = Math.max(0, maxBytes - extensionBytes); - // Walk the stem by grapheme cluster so we never split an emoji sequence - // (e.g. ZWJ families, skin-tone modifiers) or a base+combining-mark pair. const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); let truncatedStem = ""; let usedBytes = 0; diff --git a/frontend/sync-client/src/sync-operations/offline-change-detector.ts b/frontend/sync-client/src/sync-operations/offline-change-detector.ts index c90f6a78..8bb8c27c 100644 --- a/frontend/sync-client/src/sync-operations/offline-change-detector.ts +++ b/frontend/sync-client/src/sync-operations/offline-change-detector.ts @@ -1,4 +1,4 @@ -import type { DocumentRecord, RelativePath } from "./types"; +import type { DocumentRecord, DocumentWithPath, RelativePath } from "./types"; import { SyncEventType } from "./types"; import type { Logger } from "../tracing/logger"; import { hash } from "../utils/hash"; @@ -6,23 +6,9 @@ import type { FileOperations } from "../file-operations/file-operations"; import { findMatchingFile } from "../utils/find-matching-file"; import { FileNotFoundError } from "../errors/file-not-found-error"; import type { SyncEventQueue } from "./sync-event-queue"; +import { removeFromArray } from "../utils/remove-from-array"; -interface DocumentWithPath { - path: RelativePath; - record: DocumentRecord; -} -interface SyncInstruction { - type: "update" | "create"; - relativePath: string; - oldPath?: string; -} - -interface OfflineChangeDetectorDeps { - logger: Logger; - operations: FileOperations; - queue: SyncEventQueue; -} /** * Scans the local filesystem and the document database to determine @@ -30,218 +16,64 @@ interface OfflineChangeDetectorDeps { * client was offline, then enqueues the appropriate sync events. */ export async function scheduleOfflineChanges( - deps: OfflineChangeDetectorDeps, + logger: Logger, + operations: FileOperations, + queue: SyncEventQueue, enqueueCreate: (path: RelativePath) => void, enqueueUpdate: (args: { oldPath?: RelativePath; relativePath: RelativePath }) => void, enqueueDelete: (path: RelativePath) => void, ): Promise { - const { logger, operations, queue } = deps; - const allLocalFiles = await operations.listFilesRecursively(); logger.info(`Scheduling sync for ${allLocalFiles.length} local files`); + const allDocuments = queue.allSettledDocuments(); - queue.clear(); + const locallyPossiblyDeletedFiles: DocumentWithPath[] = []; - const allDocuments = new Map(queue.allSettledDocuments()); - const locallyRenamedPaths = enqueueRenamedDocuments(deps, allDocuments); - - const deletedCandidates = await findLocallyDeletedFiles(operations, allDocuments); - - const instructions = await buildSyncInstructions( - deps, - allLocalFiles, - locallyRenamedPaths, - deletedCandidates, - ); - - // Enqueue deletes first - for (const { path } of deletedCandidates) { - logger.debug(`Document ${path} has been deleted locally, scheduling sync to delete it`); - enqueueDelete(path); - } - - // Then updates/moves - for (const instruction of instructions) { - if (instruction.type === "update") { - enqueueUpdate({ - oldPath: instruction.oldPath, - relativePath: instruction.relativePath, - }); + for (const [path, record] of allDocuments.entries()) { + if ( + record !== undefined + ) { + locallyPossiblyDeletedFiles.push({ path, record }); } } - // Creates last so the server can merge with existing documents - for (const instruction of instructions) { - if (instruction.type === "create") { - enqueueCreate(instruction.relativePath); - } - } -} + const locallyPossibleCreatedFiles: RelativePath[] = []; + const syncedLocalFiles: RelativePath[] = []; -function enqueueRenamedDocuments( - { queue, logger }: OfflineChangeDetectorDeps, - allDocuments: Map, -): Set { - const locallyRenamedPaths = new Set(); - - for (const [path, record] of allDocuments) { - const remoteRelPath = record.remoteRelativePath; - const hasLocalRename = remoteRelPath !== undefined && remoteRelPath !== path; - - if (hasLocalRename) { - queue.enqueue({ type: SyncEventType.LocalUpdate, path }); - locallyRenamedPaths.add(path); - logger.debug(`Document ${path} was renamed locally (from ${remoteRelPath}), scheduling sync`); + for (const localFile of allLocalFiles) { + if (allDocuments.has(localFile) + ) { + syncedLocalFiles.push(localFile); + } else { + locallyPossibleCreatedFiles.push(localFile); } } - return locallyRenamedPaths; -} - -async function findLocallyDeletedFiles( - operations: FileOperations, - allDocuments: Map, -): Promise { - const result: DocumentWithPath[] = []; - - for (const [path, record] of allDocuments) { - if (!(await operations.exists(path))) { - result.push({ path, record }); - } - } - - return result; -} - -async function buildSyncInstructions( - deps: OfflineChangeDetectorDeps, - allLocalFiles: RelativePath[], - locallyRenamedPaths: Set, - deletedCandidates: DocumentWithPath[], -): Promise { - const { logger, operations, queue } = deps; - const instructions: SyncInstruction[] = []; - - for (const relativePath of allLocalFiles) { - if (locallyRenamedPaths.has(relativePath)) { - continue; - } - - const existingRecord = queue.getSettledDocumentByPath(relativePath); - - if (existingRecord !== undefined) { - const result = await handleExistingDocument( - deps, - relativePath, - existingRecord, - deletedCandidates, - ); - if (result !== undefined) { - if (result.updatedDeletedCandidates !== undefined) { - deletedCandidates = result.updatedDeletedCandidates; - } - if (result.instruction !== undefined) { - instructions.push(result.instruction); - } - continue; - } + for (const path of locallyPossibleCreatedFiles) { + const content = await operations.read(path); + const contentHash = await hash(content); + const matchingDeletedFile = await findMatchingFile(contentHash, locallyPossiblyDeletedFiles); + if (matchingDeletedFile !== undefined) { logger.debug( - `Document ${relativePath} might have been updated locally, scheduling sync to validate and update it`, + `File ${path} might have been moved from ${matchingDeletedFile.path} while offline, scheduling sync to move it`, ); - instructions.push({ type: "update", relativePath }); - continue; + enqueueUpdate({ oldPath: matchingDeletedFile.path, relativePath: path }); + removeFromArray(locallyPossiblyDeletedFiles, matchingDeletedFile); + removeFromArray(locallyPossibleCreatedFiles, path); } - - const result = await handleNewFile(deps, relativePath, deletedCandidates); - if (result.updatedDeletedCandidates !== undefined) { - deletedCandidates = result.updatedDeletedCandidates; - } - instructions.push(result.instruction); } - return instructions; -} - -async function handleExistingDocument( - { logger, operations }: OfflineChangeDetectorDeps, - relativePath: RelativePath, - existingRecord: DocumentRecord, - deletedCandidates: DocumentWithPath[], -): Promise< - | { instruction?: SyncInstruction; updatedDeletedCandidates?: DocumentWithPath[] } - | undefined -> { - if (deletedCandidates.length === 0) { - return undefined; - } - - let contentHash: string | undefined; - try { - const bytes = await operations.read(relativePath); - contentHash = await hash(bytes); - } catch (e) { - if (e instanceof FileNotFoundError) return { instruction: undefined }; - throw e; - } - - if (contentHash === existingRecord.remoteHash) { - return undefined; - } - - const originalFile = await findMatchingFile(contentHash, deletedCandidates); - if (originalFile === undefined) { - return undefined; - } - - // This file was moved here from a different path, displacing the existing document - const updatedDeletedCandidates = [ - ...deletedCandidates.filter((item) => item.path !== originalFile.path), - { path: relativePath, record: existingRecord }, - ]; - - logger.debug( - `Document '${originalFile.path}' was moved to ${relativePath} (displacing existing document), scheduling sync to move it`, - ); - - return { - instruction: { type: "update", oldPath: originalFile.path, relativePath }, - updatedDeletedCandidates, - }; -} - -async function handleNewFile( - { logger, operations }: OfflineChangeDetectorDeps, - relativePath: RelativePath, - deletedCandidates: DocumentWithPath[], -): Promise<{ instruction: SyncInstruction; updatedDeletedCandidates?: DocumentWithPath[] }> { - let contentHash: string | undefined; - try { - const contentBytes = await operations.read(relativePath); - contentHash = await hash(contentBytes); - } catch (e) { - if (e instanceof FileNotFoundError) { - return { instruction: { type: "create", relativePath } }; - } - throw e; - } - - const originalFile = await findMatchingFile(contentHash, deletedCandidates); - if (originalFile !== undefined) { - const updatedDeletedCandidates = deletedCandidates.filter( - (item) => item.path !== originalFile.path, - ); - - logger.debug( - `Document '${originalFile.path}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it`, - ); - - return { - instruction: { type: "update", oldPath: originalFile.path, relativePath }, - updatedDeletedCandidates, - }; - } - - logger.debug(`Document ${relativePath} not found in database, scheduling sync to create it`); - return { instruction: { type: SyncEventType.LocalCreate, relativePath } }; + for (const path of locallyPossibleCreatedFiles) { + logger.debug(`File ${path} was created while offline, scheduling sync to create it`); + enqueueCreate(path); + } + + for (const item of locallyPossiblyDeletedFiles) { + enqueueDelete(item.path); + } + + for (const path of syncedLocalFiles) { + enqueueUpdate({ relativePath: path }); + } } diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts index 23f31891..3e644057 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts @@ -316,7 +316,7 @@ describe("SyncEventQueue", () => { assert.ok(promiseA !== undefined); assert.ok(promiseB !== undefined); - queue.clear(); + queue.clearPending(); await assert.rejects(promiseA); await assert.rejects(promiseB); @@ -360,7 +360,7 @@ describe("SyncEventQueue", () => { assert.strictEqual(queue.pendingUpdateCount, 2); - queue.clear(); + queue.clearPending(); assert.strictEqual(queue.pendingUpdateCount, 0); assert.strictEqual(queue.syncedDocumentCount, 1); diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index 401541b1..a65c2fbd 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -4,6 +4,7 @@ import { globsToRegexes } from "../utils/globs-to-regexes"; import { CONFLICT_PATH_REGEX } from "./conflict-path"; import { removeFromArray } from "../utils/remove-from-array"; import { + DocumentWithPath, SyncEventType, type DocumentId, type DocumentRecord, @@ -50,7 +51,7 @@ export class SyncEventQueue { private readonly saveData: (data: StoredSyncState) => Promise ) { this.ignorePatterns = [ - CONFLICT_PATH_REGEX, + CONFLICT_PATH_REGEX, // conflict paths need to be resolved before they can be synced again ...globsToRegexes( this.settings.getSettings().ignorePatterns, this.logger @@ -85,12 +86,7 @@ export class SyncEventQueue { } public enqueue(input: FileSyncEvent): void { - if (input.type === SyncEventType.RemoteChange) { - this.events.push(input); - return; - } - - const { path } = input; + const path = (input.type === SyncEventType.RemoteChange) ? input.remoteVersion.relativePath : input.path; if (this.ignorePatterns.some((pattern) => pattern.test(path))) { this.logger.info( @@ -99,6 +95,12 @@ export class SyncEventQueue { return; } + if (input.type === SyncEventType.RemoteChange) { + this.events.push(input); + return; + } + + if (input.type === SyncEventType.LocalCreate) { this.events.push({ type: SyncEventType.LocalCreate, path, originalPath: path, resolvers: Promise.withResolvers() }); return; @@ -111,7 +113,6 @@ export class SyncEventQueue { if (documentId === undefined) { // we can get here when deleting a local document after a remote update - return; } @@ -173,7 +174,7 @@ export class SyncEventQueue { public getDocumentByDocumentId( target: DocumentId - ): { path: RelativePath; record: DocumentRecord } | undefined { + ): DocumentWithPath | undefined { for (const [path, record] of this.documents) { if (record.documentId === target) { return { path, record }; @@ -186,7 +187,7 @@ export class SyncEventQueue { public getDocumentByDocumentIdOrFail( target: DocumentId - ): { path: RelativePath; record: DocumentRecord } { + ): DocumentWithPath { const result = this.getDocumentByDocumentId(target); if (!result) { throw new Error(`No document found with id ${target}`); @@ -215,48 +216,10 @@ export class SyncEventQueue { return this.documents.get(path); } - - - - - public allSettledDocuments(): [RelativePath, DocumentRecord][] { - return Array.from(this.documents.entries()); + public allSettledDocuments(): Map { + return new Map(this.documents.entries()); } - /** - * Returns the set of paths we expect to exist on disk by replaying - * the event queue on top of the settled documents map. - */ - public trackedPaths(): Set { - const paths = new Set(this.documents.keys()); - // Track current path for each pending create so moves can be applied - const pendingPaths = new Map, RelativePath>(); - - for (const event of this.events) { - if (event.type === SyncEventType.LocalCreate) { - paths.add(event.path); - if (event.resolvers !== undefined) { - pendingPaths.set(event.resolvers.promise, event.path); - } - } else if (event.type === SyncEventType.LocalDelete) { - if (typeof event.documentId === "string") { - const path = this.getDocumentByDocumentId(event.documentId)?.path; - if (path) { - paths.delete(path); - } else { - throw new Error(`Delete event for unknown documentId ${event.documentId}`); - } - } else { - const path = pendingPaths.get(event.documentId); - if (!path) { - throw new Error(`Delete event with unresolved documentId promise`); - } - paths.delete(path); - } - } // no need to handle SyncLocal as path updates are applied to this.documents immediately when the event is enqueued - } - return paths; - } public hasPendingEventsForPath(path: RelativePath): boolean { const record = this.documents.get(path); @@ -288,36 +251,19 @@ export class SyncEventQueue { } - public resetState(): void { - this.rejectAllPendingCreates(); + public async clearAllState(): Promise { + this.clearPending(); this.documents.clear(); - this.saveInTheBackground(); + this.lastSeenUpdateId = -1; + await this.save(); } - public clear(): void { + public clearPending(): void { this.rejectAllPendingCreates(); this.events.length = 0; } - - public removeAllEventsForDocumentId(documentId: DocumentId): void { - for (let i = this.events.length - 1; i >= 0; i--) { - const e = this.events[i]; - if ( - (e.type === SyncEventType.LocalUpdate && - e.documentId === documentId) || - (e.type === SyncEventType.RemoteChange && - e.remoteVersion.documentId === documentId) || - (e.type === SyncEventType.LocalDelete && - e.documentId === documentId) - ) { - // eslint-disable-next-line no-restricted-syntax -- Bulk removal by predicate, not single-item removal - this.events.splice(i, 1); - } - } - } - private updatePendingCreatePath( oldPath: RelativePath, newPath: RelativePath diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 3dbcd6cf..bf488749 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -154,7 +154,7 @@ export class Syncer { public reset(): void { this._isFirstSyncStarted = false; - this.queue.clear(); + this.queue.clearPending(); // Don't null the reference synchronously — if the scan is // still in flight, the next reconnect would spawn a second // concurrent scan racing on the same queue. Defer the diff --git a/frontend/sync-client/src/sync-operations/types.ts b/frontend/sync-client/src/sync-operations/types.ts index 9642665a..9d2cedac 100644 --- a/frontend/sync-client/src/sync-operations/types.ts +++ b/frontend/sync-client/src/sync-operations/types.ts @@ -11,6 +11,11 @@ export interface DocumentRecord { remoteRelativePath: RelativePath; } +export interface DocumentWithPath { + path: RelativePath; + record: DocumentRecord; +} + export interface StoredDocument extends DocumentRecord { relativePath: RelativePath; } @@ -29,7 +34,9 @@ export enum SyncEventType { export type FileSyncEvent = | { type: SyncEventType.LocalCreate; path: RelativePath } - | { type: SyncEventType.LocalUpdate; path: RelativePath; oldPath?: RelativePath } + | { + type: SyncEventType.LocalUpdate; path: RelativePath; oldPath?: RelativePath // oldPath is undefined for content changes + } | { type: SyncEventType.LocalDelete; path: RelativePath } | { type: SyncEventType.RemoteChange; remoteVersion: DocumentVersionWithoutContent }; diff --git a/frontend/sync-client/src/utils/find-matching-file.ts b/frontend/sync-client/src/utils/find-matching-file.ts index 4c748925..1b8df384 100644 --- a/frontend/sync-client/src/utils/find-matching-file.ts +++ b/frontend/sync-client/src/utils/find-matching-file.ts @@ -1,11 +1,11 @@ -import type { DocumentRecord, RelativePath } from "../sync-operations/types"; +import type { DocumentRecord, DocumentWithPath, RelativePath } from "../sync-operations/types"; import { EMPTY_HASH } from "./hash"; // TODO: make this smarter so that offline files can be renamed & edited at the same time export async function findMatchingFile( contentHash: string, candidates: { path: RelativePath; record: DocumentRecord }[] -): Promise<{ path: RelativePath; record: DocumentRecord } | undefined> { +): Promise { if (contentHash === await EMPTY_HASH) { return undefined; } From addaa1699f09851dbe256bb2c8b71dbc621e4580 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 13:53:16 +0100 Subject: [PATCH 15/52] missing ensure and covered --- .../src/sync-operations/sync-event-queue.ts | 40 +++++++++---------- .../sync-client/src/sync-operations/syncer.ts | 16 +++----- 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index a65c2fbd..72ed56fe 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -39,7 +39,6 @@ export class SyncEventQueue { // file creations for paths matching any of these patterns will be ignored private ignorePatterns: RegExp[]; - private savePending = false; public readonly lastSeenUpdateId: VaultUpdateId; @@ -85,7 +84,7 @@ export class SyncEventQueue { return this.documents.size; } - public enqueue(input: FileSyncEvent): void { + public async enqueue(input: FileSyncEvent): Promise { const path = (input.type === SyncEventType.RemoteChange) ? input.remoteVersion.relativePath : input.path; if (this.ignorePatterns.some((pattern) => pattern.test(path))) { @@ -108,21 +107,30 @@ export class SyncEventQueue { const lookupPath = (input.type === SyncEventType.LocalUpdate && input.oldPath) ? input.oldPath : path; const record = this.documents.get(lookupPath); - const documentId: DocumentId | Promise | undefined = - this.findLatestCreateForPath(lookupPath)?.resolvers.promise ?? record?.documentId; - if (documentId === undefined) { + // latest creation must take precedence as it's from the doc's latest generation + const pendingDocumentId: Promise | undefined = + this.findLatestCreateForPath(lookupPath)?.resolvers.promise; + + const documentId: DocumentId | undefined = + record?.documentId; + + + if (pendingDocumentId === undefined && documentId === undefined) { // we can get here when deleting a local document after a remote update return; } if (input.type === SyncEventType.LocalDelete) { - this.events.push({ type: SyncEventType.LocalDelete, documentId }); + this.events.push({ type: SyncEventType.LocalDelete, documentId: pendingDocumentId ?? documentId! }); return; } if (input.oldPath !== undefined) { - if (typeof documentId === "string") { + if (pendingDocumentId !== undefined) { + this.updatePendingCreatePath(input.oldPath, path); + this.events.push({ type: SyncEventType.LocalUpdate, documentId: pendingDocumentId, path, originalPath: path }); + } else { this.documents.delete(input.oldPath); this.documents.set(path, record!); for (const e of this.events) { @@ -131,12 +139,11 @@ export class SyncEventQueue { e.path = path; } } - this.saveInTheBackground(); - } else { - this.updatePendingCreatePath(input.oldPath, path); + this.events.push({ type: SyncEventType.LocalUpdate, documentId: documentId!, path, originalPath: path }); + await this.save(); + } } - this.events.push({ type: SyncEventType.LocalUpdate, documentId, path, originalPath: path }); } @@ -312,15 +319,4 @@ export class SyncEventQueue { } - // Coalesce bursts of mutations into one persist per microtask. A drain - // iteration can easily produce 10+ mutations; without this, we'd fire - // 10 overlapping `save()` calls racing on the persistence backend. - private saveInTheBackground(): void { - if (this.savePending) return; - this.savePending = true; - queueMicrotask(() => { - this.savePending = false; - this.save(); - }); - } } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index bf488749..77144462 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -79,7 +79,7 @@ export class Syncer { } public syncLocallyCreatedFile(relativePath: RelativePath): void { - this.queue.enqueue({ type: SyncEventType.LocalCreate, path: relativePath }); + void this.queue.enqueue({ type: SyncEventType.LocalCreate, path: relativePath }); this.ensureDraining(); } @@ -90,12 +90,12 @@ export class Syncer { oldPath?: RelativePath; relativePath: RelativePath; }): void { - this.queue.enqueue({ type: SyncEventType.LocalUpdate, path: relativePath, oldPath }); + void this.queue.enqueue({ type: SyncEventType.LocalUpdate, path: relativePath, oldPath }); this.ensureDraining(); } public syncLocallyDeletedFile(relativePath: RelativePath): void { - this.queue.enqueue({ + void this.queue.enqueue({ type: SyncEventType.LocalDelete, path: relativePath, }); @@ -107,7 +107,7 @@ export class Syncer { ): Promise { await this.scheduleSyncForOfflineChanges(); - this.queue.enqueue({ + void this.queue.enqueue({ type: SyncEventType.RemoteChange, remoteVersion: message.document }); @@ -189,18 +189,13 @@ export class Syncer { private async internalScheduleSyncForOfflineChanges(): Promise { - // Offline scan wipes the event queue via `queue.clear()` and then - // rebuilds events from disk. That MUST NOT race against an - // in-flight drain iteration that may already hold a reference to - // a freshly-cleared event — wait for any drain to finish, and - // suppress new drains for the duration of the scan. this.isScanning = true; try { while (this.drainPromise !== undefined) { await this.drainPromise; } await scheduleOfflineChanges( - { logger: this.logger, operations: this.operations, queue: this.queue }, + this.logger, this.operations, this.queue, (path) => { this.syncLocallyCreatedFile(path); }, (args) => { this.syncLocallyUpdatedFile(args); }, (path) => { this.syncLocallyDeletedFile(path); }, @@ -210,7 +205,6 @@ export class Syncer { } this.ensureDraining(); - await this.drainPromise; } From 321b503379a9ac8d0870339e13f37218eb2fb40c Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 14:24:39 +0100 Subject: [PATCH 16/52] add min covered --- .../sync-operations/sync-event-queue.test.ts | 2 +- .../src/sync-operations/sync-event-queue.ts | 21 +++-- .../sync-client/src/sync-operations/syncer.ts | 57 ++++++++++++-- .../utils/data-structures/min-covered.test.ts | 76 +++++++++++++++++++ .../src/utils/data-structures/min-covered.ts | 66 ++++++++++++++++ 5 files changed, 206 insertions(+), 16 deletions(-) create mode 100644 frontend/sync-client/src/utils/data-structures/min-covered.test.ts create mode 100644 frontend/sync-client/src/utils/data-structures/min-covered.ts diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts index 3e644057..ee9967a0 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts @@ -410,7 +410,7 @@ describe("SyncEventQueue", () => { assert.strictEqual(queue.syncedDocumentCount, 2); assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "A"); assert.strictEqual(queue.getSettledDocumentByPath("b.md")?.documentId, "B"); - assert.strictEqual(queue.lastSeenUpdateId, 5); + assert.strictEqual(queue._lastSeenUpdateId, 5); }); it("trackedPaths combines documents and pending events", () => { diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index 72ed56fe..5fb9c8c6 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -14,6 +14,7 @@ import { type SyncEvent, type VaultUpdateId, } from "./types"; +import { MinCovered } from "../utils/data-structures/min-covered"; export class SyncEventQueue { @@ -39,9 +40,7 @@ export class SyncEventQueue { // file creations for paths matching any of these patterns will be ignored private ignorePatterns: RegExp[]; - - - public readonly lastSeenUpdateId: VaultUpdateId; + public _lastSeenUpdateId: MinCovered; public constructor( private readonly settings: Settings, @@ -71,9 +70,17 @@ export class SyncEventQueue { this.documents.set(relativePath, record); } } - this.lastSeenUpdateId = initialState.lastSeenUpdateId ?? -1; + this._lastSeenUpdateId = new MinCovered(initialState.lastSeenUpdateId ?? 0); - this.logger.debug(`Loaded ${this.documents.size} documents and lastSeenUpdateId=${this.lastSeenUpdateId} from storage`); + this.logger.debug(`Loaded ${this.documents.size} documents and lastSeenUpdateId=${this._lastSeenUpdateId} from storage`); + } + + public get lastSeenUpdateId(): VaultUpdateId { + return this._lastSeenUpdateId.min; + } + + public set lastSeenUpdateId(id: VaultUpdateId) { + this._lastSeenUpdateId.add(id); } public get pendingUpdateCount(): number { @@ -214,7 +221,7 @@ export class SyncEventQueue { ...record }) ), - lastSeenUpdateId: this.lastSeenUpdateId + lastSeenUpdateId: this._lastSeenUpdateId }); } @@ -261,7 +268,7 @@ export class SyncEventQueue { public async clearAllState(): Promise { this.clearPending(); this.documents.clear(); - this.lastSeenUpdateId = -1; + this._lastSeenUpdateId.reset() await this.save(); } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 77144462..a44c6efb 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -378,6 +378,7 @@ export class Syncer { createEvent: event }); + this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { type: SyncType.CREATE, relativePath: effectivePath }, @@ -403,6 +404,8 @@ export class Syncer { }); await this.queue.removeDocument(doc.path); + this.queue.lastSeenUpdateId = response.vaultUpdateId; + this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, @@ -443,6 +446,9 @@ export class Syncer { } ); + this.queue.lastSeenUpdateId = response.vaultUpdateId; + + await this.handleMaybeMergingResponse({ path: diskPath, response, @@ -530,6 +536,8 @@ export class Syncer { remoteHash }); } + + this.queue.lastSeenUpdateId = response.vaultUpdateId; } @@ -549,7 +557,13 @@ export class Syncer { return this.processRemoteDelete(documentWithPath.path, remoteVersion); } - + if (documentWithPath?.record.parentVersionId ?? 0 >= remoteVersion.vaultUpdateId) { + this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; + this.logger.debug( + `Document ${remoteVersion.relativePath} is already up-to-date or has newer local changes; skipping remote update` + ); + return; + } if (documentWithPath !== undefined) { // must be the update to an existing doc @@ -570,6 +584,9 @@ export class Syncer { await this.operations.delete(path); await this.queue.removeDocument(path); + this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; + + this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { @@ -599,12 +616,30 @@ export class Syncer { const currentContent = await this.operations.read(path); const remoteContent = await this.syncService.getDocumentVersionContent({ documentId: remoteVersion.documentId, vaultUpdateId: remoteVersion.vaultUpdateId }); this.operations.write(path, currentContent, remoteContent); - // todo: update last seen id + + await this.updateCache( + remoteVersion.vaultUpdateId, + remoteContent, + path + ); + this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; } // else we don't need to update the content, a subsequent local update will do that + this.syncRemotelyUpdatedFile({ // schedule it so that the lastSeenUpdateId remains consistent + document: + remoteVersion + }) + + + // wait for a local edit to do the actual updating here, so we can't even update the lastSeenUpdateId here this.ensurePath(path, remoteVersion.relativePath); + this.queue.setDocument(remoteVersion.relativePath, { + ...record, + remoteRelativePath: remoteVersion.relativePath + }); + this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { @@ -642,6 +677,8 @@ export class Syncer { remoteRelativePath: remoteVersion.relativePath }); + this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; + this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { @@ -682,12 +719,6 @@ export class Syncer { if (canMergeText) { const currentContent = await this.operations.read(pendingCreateEvent.path); - this.queue.resolveCreate(pendingCreateEvent, { - documentId: remoteVersion.documentId, - parentVersionId: remoteVersion.vaultUpdateId, - remoteHash, - remoteRelativePath: path - }); const merged = reconcile("", new TextDecoder().decode(currentContent), new TextDecoder().decode(remoteContent)).text; @@ -698,6 +729,14 @@ export class Syncer { path ); + await this.queue.resolveCreate(pendingCreateEvent, { + documentId: remoteVersion.documentId, + parentVersionId: remoteVersion.vaultUpdateId, + remoteHash, + remoteRelativePath: path + }); + this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; + this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { @@ -719,6 +758,8 @@ export class Syncer { remoteHash, remoteRelativePath: path }); + this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; + this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { diff --git a/frontend/sync-client/src/utils/data-structures/min-covered.test.ts b/frontend/sync-client/src/utils/data-structures/min-covered.test.ts new file mode 100644 index 00000000..8ebc94a4 --- /dev/null +++ b/frontend/sync-client/src/utils/data-structures/min-covered.test.ts @@ -0,0 +1,76 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { MinCovered } from "./min-covered"; + +describe("MinCovered", () => { + it("should initialize with the given min value", () => { + const covered = new MinCovered(5); + assert.strictEqual(covered.min, 5); + }); + + it("should add values greater than min", () => { + const covered = new MinCovered(0); + covered.add(3); + assert.strictEqual(covered.min, 0); + covered.add(1); + assert.strictEqual(covered.min, 1); + covered.add(4); + assert.strictEqual(covered.min, 1); + covered.add(2); + assert.strictEqual(covered.min, 4); + }); + + it("should ignore duplicate values", () => { + const covered = new MinCovered(0); + covered.add(3); + covered.add(3); + covered.add(3); + assert.strictEqual(covered.min, 0); + covered.add(1); + covered.add(2); + assert.strictEqual(covered.min, 3); + }); + + it("should handle multiple consecutive values", () => { + const covered = new MinCovered(132); + for (let i = 250; i > 132; i--) { + assert.strictEqual(covered.min, 132); + covered.add(i); + } + assert.strictEqual(covered.min, 250); + }); + + it("should handle adding values lower than current min", () => { + const covered = new MinCovered(5); + covered.add(3); + assert.strictEqual(covered.min, 5); + covered.add(6); + assert.strictEqual(covered.min, 6); + }); + + it("should auto-advance when setting min value", () => { + const covered = new MinCovered(5); + covered.add(7); + covered.add(8); + covered.add(9); + assert.strictEqual(covered.min, 5); + // Setting min to 6 should auto-advance through 7, 8, 9 + covered.min = 6; + assert.strictEqual(covered.min, 9); + covered.add(10); + assert.strictEqual(covered.min, 10); + }); + + it("should handle setting min value with no consecutive values", () => { + const covered = new MinCovered(5); + covered.add(10); + covered.add(15); + assert.strictEqual(covered.min, 5); + // Setting min to 8 should not auto-advance (no consecutive values) + covered.min = 8; + assert.strictEqual(covered.min, 8); + // Add 9 to trigger auto-advance to 10 + covered.add(9); + assert.strictEqual(covered.min, 10); + }); +}); diff --git a/frontend/sync-client/src/utils/data-structures/min-covered.ts b/frontend/sync-client/src/utils/data-structures/min-covered.ts new file mode 100644 index 00000000..720e20a3 --- /dev/null +++ b/frontend/sync-client/src/utils/data-structures/min-covered.ts @@ -0,0 +1,66 @@ +/** + * A class that tracks the minimum covered value in a sequence of numbers. + * It keeps track of a minimum value based on the seen values. + * + * It expects integers slightly out of order and makes sure that the value of `min` is + * always the minimum of the seen values. This is done with bounded memory usage. + * + * @example + * ```typescript + * const covered = new MinCovered(0); + * covered.add(2); // seenValues = [2], min = 0 + * covered.add(1); // seenValues = [], min = 2 + * covered.min; // returns 2 + * ``` + */ +export class MinCovered { + private seenValues: number[] = []; + + public constructor(private minValue: number) { } + + public get min(): number { + return this.minValue; + } + + public set min(value: number) { + this.minValue = Math.max(value, this.minValue); + this.seenValues = this.seenValues.filter((v) => v > this.minValue); + this.advanceMinWhilePossible(); + } + + public add(value: number | undefined): void { + if (value === undefined || value < this.minValue) { + return; + } + + let i = 0; + while (i < this.seenValues.length && this.seenValues[i] < value) { + i++; + } + + if (i === this.seenValues.length) { + this.seenValues.push(value); + } else if (this.seenValues[i] === value) { + return; + } else { + this.seenValues.splice(i, 0, value); + } + + this.advanceMinWhilePossible(); + } + + public reset(minValue?: number): void { + this.minValue = minValue ?? 0; + this.seenValues = []; + } + + private advanceMinWhilePossible(): void { + while ( + this.seenValues.length > 0 && + this.seenValues[0] === this.minValue + 1 + ) { + this.seenValues.shift(); + this.minValue++; + } + } +} From 081e35be5c821a33baf1ce4e5e530a334427fb9a Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 15:39:56 +0100 Subject: [PATCH 17/52] fix conflict path handling --- .../file-operations/file-operations.test.ts | 1 - .../src/file-operations/file-operations.ts | 72 +++++------ .../safe-filesystem-operations.ts | 80 ++---------- frontend/sync-client/src/sync-client.ts | 2 - .../src/sync-operations/sync-event-queue.ts | 3 +- .../sync-client/src/sync-operations/syncer.ts | 117 +++++++----------- 6 files changed, 91 insertions(+), 184 deletions(-) diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index a69a5429..14606eb0 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -87,7 +87,6 @@ function makeOps(): { const fs = new FakeFileSystemOperations(); const ops = new FileOperations( new Logger(), - new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion fs, new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion ); diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 530a6bcc..9cb4f521 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -10,12 +10,17 @@ import { isBinary } from "../utils/is-binary"; import { buildConflictFileName } from "../sync-operations/conflict-path"; import type { ServerConfig } from "../services/server-config"; + +export enum MoveOnConflict { + EXISTING = "EXISTING", + NEW = "NEW", +} + export class FileOperations { private readonly fs: SafeFileSystemOperations; public constructor( private readonly logger: Logger, - private readonly queue: SyncEventQueue, fs: FileSystemOperations, private readonly serverConfig: ServerConfig, private readonly nativeLineEndings = "\n" @@ -50,44 +55,44 @@ export class FileOperations { * * If a file with the same name already exists, it is moved before creating the new one. * Parent directories are created if necessary. + * + * Returns the actual path the file was created at. */ public async create( path: RelativePath, - newContent: Uint8Array - ): Promise { - await this.ensureClearPath(path); - return this.fs.write(path, this.toNativeLineEndings(newContent)); + newContent: Uint8Array, + moveOnConflict: MoveOnConflict + ): Promise { + const actualPath = await this.ensureClearPath(path, moveOnConflict); + await this.fs.write(actualPath, this.toNativeLineEndings(newContent)); + return actualPath; } /** * Ensure nothing sits at `path` so the caller can write to it. - * - * If a file is already there, it is moved aside to a `conflict--` - * path in the same directory. The sync layer treats conflict-named files - * as invisible (see `CONFLICT_PATH_REGEX`), so no events are enqueued and no - * document records are touched — any pre-existing record or pending - * events for the displaced path are left behind for the caller to - * overwrite as part of whatever operation prompted the displacement. - * - * Returns the conflict path the existing file was moved to, or `undefined` - * if the path was already clear. */ - public async ensureClearPath( - path: RelativePath - ): Promise { + private async ensureClearPath( + path: RelativePath, + moveOnConflict: MoveOnConflict + ): Promise { if (await this.fs.exists(path)) { const conflictPath = FileOperations.buildConflictPath(path); + + if (moveOnConflict === MoveOnConflict.NEW) { + return conflictPath; + } + this.logger.debug( `Displacing existing file at ${path} to '${conflictPath}' to make room` ); - this.queue.moveDocument(path, conflictPath); - await this.fs.rename(path, conflictPath, true); + await this.fs.rename(path, conflictPath); return conflictPath; } + this.logger.debug(`No existing file at ${path}, creating parent directories if needed`); await this.createParentDirectories(path); - return undefined; + return path; } /** @@ -188,32 +193,23 @@ export class FileOperations { return this.fs.exists(path); } - // Returns the conflict path a displaced file was moved to, or undefined. + // Returns the actual path the file got moved to. public async move( oldPath: RelativePath, - newPath: RelativePath - ): Promise { + newPath: RelativePath, + moveOnConflict: MoveOnConflict + ): Promise { if (oldPath === newPath) { - return undefined; + return oldPath; } - const conflictPath = await this.ensureClearPath(newPath); - // Do the disk rename *before* updating the queue. If the rename - // throws (permissions, concurrent deletion, …), the queue still - // reflects the actual on-disk state instead of claiming the doc - // has already moved. - await this.fs.rename(oldPath, newPath); - this.queue.moveDocument(oldPath, newPath); - + const actualPath = await this.ensureClearPath(newPath, moveOnConflict); + await this.fs.rename(oldPath, actualPath); await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); - return conflictPath; + return actualPath; } - public reset(): void { - this.fs.reset(); - } - private async deletingEmptyParentDirectoriesOfDeletedFile( path: RelativePath ): Promise { diff --git a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts index 8c297920..89b5008c 100644 --- a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts @@ -1,24 +1,18 @@ import type { RelativePath } from "../sync-operations/types"; import type { FileSystemOperations } from "./filesystem-operations"; import type { Logger } from "../tracing/logger"; -import { Locks } from "../utils/data-structures/locks"; import { FileNotFoundError } from "../errors/file-not-found-error"; import type { TextWithCursors } from "reconcile-text"; /** * Decorates `FileSystemOperations` to replace errors with `FileNotFoundError` - * if the accessed file doesn't exist. It also ensures that there's at most a - * single request in-flight for any one file through the use of locks. + * if the accessed file doesn't exist. */ export class SafeFileSystemOperations implements FileSystemOperations { - private readonly locks: Locks; - public constructor( private readonly fs: FileSystemOperations, private readonly logger: Logger - ) { - this.locks = new Locks(SafeFileSystemOperations.name, logger); - } + ) {} public async listFilesRecursively( root: RelativePath | undefined @@ -31,19 +25,12 @@ export class SafeFileSystemOperations implements FileSystemOperations { 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" - ); + return this.safeOperation(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) - ); + return this.fs.write(path, content); } public async atomicUpdateText( @@ -53,10 +40,7 @@ export class SafeFileSystemOperations implements FileSystemOperations { this.logger.debug(`Atomically updating file '${path}'`); return this.safeOperation( path, - async () => - this.locks.withLock(path, async () => - this.fs.atomicUpdateText(path, updater) - ), + async () => this.fs.atomicUpdateText(path, updater), "atomicUpdateText" ); } @@ -65,75 +49,38 @@ export class SafeFileSystemOperations implements FileSystemOperations { // Logging this would be too noisy return this.safeOperation( path, - async () => - this.locks.withLock(path, async () => - this.fs.getFileSize(path) - ), + async () => this.fs.getFileSize(path), "getFileSize" ); } - public async exists( - path: RelativePath, - skipLock = false - ): Promise { + public async exists(path: RelativePath): 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)); - } + return 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) - ); + return 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)); + return this.fs.delete(path); } public async rename( oldPath: RelativePath, - newPath: RelativePath, - skipLock = false + newPath: RelativePath ): 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) - ); - } - }, + 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 @@ -154,9 +101,6 @@ export class SafeFileSystemOperations implements FileSystemOperations { try { return await operation(); } catch (error) { - // Without locking the file, this isn't atomic, however, it's good enough in practice. - // This will only break if the file exists, gets deleted and then immediately - // recreated while `operation` is running. if (await this.fs.exists(path)) { throw error; } else { diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 65580420..902c7b26 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -172,7 +172,6 @@ export class SyncClient { const fileOperations = new FileOperations( logger, - syncEventQueue, fs, serverConfig, nativeLineEndings @@ -489,7 +488,6 @@ export class SyncClient { this.contentCache.reset(); this.cursorTracker.reset(); this.syncer.reset(); - this.fileOperations.reset(); } private async onSettingsChange( diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index 5fb9c8c6..ba008753 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -221,7 +221,7 @@ export class SyncEventQueue { ...record }) ), - lastSeenUpdateId: this._lastSeenUpdateId + lastSeenUpdateId: this.lastSeenUpdateId }); } @@ -230,6 +230,7 @@ export class SyncEventQueue { return this.documents.get(path); } + public allSettledDocuments(): Map { return new Map(this.documents.entries()); } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index a44c6efb..c0334dbf 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -9,7 +9,7 @@ import { import type { Logger } from "../tracing/logger"; import { hash } from "../utils/hash"; import type { Settings } from "../persistence/settings"; -import type { FileOperations } from "../file-operations/file-operations"; +import { MoveOnConflict, type FileOperations } from "../file-operations/file-operations"; import { scheduleOfflineChanges } from "./offline-change-detector"; import { SyncResetError } from "../errors/sync-reset-error"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; @@ -523,7 +523,9 @@ export class Syncer { } if (createEvent === undefined) { - this.ensurePath(path, response.relativePath, Move.Existing); + // a http response will always be more up-to-date than any queued remote update + this.operations.move(path, response.relativePath, MoveOnConflict.EXISTING); + await this.queue.setDocument(response.relativePath, { ...record, remoteHash @@ -633,22 +635,23 @@ export class Syncer { // wait for a local edit to do the actual updating here, so we can't even update the lastSeenUpdateId here - this.ensurePath(path, remoteVersion.relativePath); + const conflictingDoc = this.queue.getSettledDocumentByPath(remoteVersion.relativePath); + const actualRelativePath = await this.operations.move(path, remoteVersion.relativePath, conflictingDoc?.parentVersionId ?? 0 < remoteVersion.vaultUpdateId ? MoveOnConflict.EXISTING : MoveOnConflict.NEW); - this.queue.setDocument(remoteVersion.relativePath, { + this.queue.setDocument(actualRelativePath, { ...record, - remoteRelativePath: remoteVersion.relativePath + remoteRelativePath: actualRelativePath }); this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { type: SyncType.MOVE, - relativePath: remoteVersion.relativePath, + relativePath: actualRelativePath, movedFrom: path }, // todo: eh - message: `File was renamed remotely from ${path} to ${remoteVersion.relativePath}`, + message: `File was renamed remotely from ${path} to ${actualRelativePath}`, }); } @@ -658,19 +661,22 @@ export class Syncer { vaultUpdateId: remoteVersion.vaultUpdateId }); - await this.operations.create( + const conflictingDoc = this.queue.getSettledDocumentByPath(remoteVersion.relativePath); + + const actualPath = await this.operations.create( remoteVersion.relativePath, - remoteContent + remoteContent, + conflictingDoc?.parentVersionId ?? 0 < remoteVersion.vaultUpdateId ? MoveOnConflict.EXISTING : MoveOnConflict.NEW ); await this.updateCache( remoteVersion.vaultUpdateId, remoteContent, - remoteVersion.relativePath + actualPath ); const contentHash = await hash(remoteContent); - await this.queue.setDocument(remoteVersion.relativePath, { + await this.queue.setDocument(actualPath, { documentId: remoteVersion.documentId, parentVersionId: remoteVersion.vaultUpdateId, remoteHash: contentHash, @@ -683,7 +689,7 @@ export class Syncer { status: SyncStatus.SUCCESS, details: { type: SyncType.CREATE, - relativePath: remoteVersion.relativePath + relativePath: actualPath }, message: "Successfully downloaded remote file which hadn't existed locally", @@ -706,72 +712,35 @@ export class Syncer { const remoteHash = await hash(remoteContent); const path = remoteVersion.relativePath; - const localContent = await this.operations.read(path); + const currentContent = await this.operations.read(pendingCreateEvent.path); - const canMergeText = - isFileTypeMergable( - path, - (await this.serverConfig.getConfig()).mergeableFileExtensions - ) && - !isBinary(localContent) && - !isBinary(remoteContent); + await this.operations.write(path, currentContent, remoteContent); + await this.updateCache( + remoteVersion.vaultUpdateId, + remoteContent, + path + ); - if (canMergeText) { - const currentContent = await this.operations.read(pendingCreateEvent.path); + await this.queue.resolveCreate(pendingCreateEvent, { + documentId: remoteVersion.documentId, + parentVersionId: remoteVersion.vaultUpdateId, + remoteHash, + remoteRelativePath: path + }); + this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: { + type: SyncType.UPDATE, + relativePath: path + }, + message: + `Adopted remote create at ${path}`, + author: remoteVersion.userId, + timestamp: new Date(remoteVersion.updatedDate) + }); - - const merged = reconcile("", new TextDecoder().decode(currentContent), new TextDecoder().decode(remoteContent)).text; - await this.operations.write(path, currentContent, new TextEncoder().encode(merged)); - await this.updateCache( - remoteVersion.vaultUpdateId, - remoteContent, - path - ); - - await this.queue.resolveCreate(pendingCreateEvent, { - documentId: remoteVersion.documentId, - parentVersionId: remoteVersion.vaultUpdateId, - remoteHash, - remoteRelativePath: path - }); - this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.UPDATE, - relativePath: path - }, - message: - `Adopted remote create at ${path}`, - author: remoteVersion.userId, - timestamp: new Date(remoteVersion.updatedDate) - }); - return; - } else { - await this.operations.ensureClearPath(path); - await this.operations.create(path, remoteContent); - await this.queue.setDocument(path, { - documentId: remoteVersion.documentId, - parentVersionId: remoteVersion.vaultUpdateId, - remoteHash, - remoteRelativePath: path - }); - this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.CREATE, - relativePath: path - }, - message: - `Created remotly created file at ${path}`, - author: remoteVersion.userId, - timestamp: new Date(remoteVersion.updatedDate) - }); - } } From fefac224b054280c51ab8b847090d52e0d2ca67d Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 16:00:07 +0100 Subject: [PATCH 18/52] Fix tests --- .../file-operations/file-operations.test.ts | 103 ++-- .../src/file-operations/file-operations.ts | 5 +- .../sync-operations/sync-event-queue.test.ts | 448 ++++-------------- .../src/sync-operations/sync-event-queue.ts | 5 +- 4 files changed, 161 insertions(+), 400 deletions(-) diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index 14606eb0..5d1129db 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -1,8 +1,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import type { RelativePath } from "../sync-operations/types"; -import type { SyncEventQueue } from "../sync-operations/sync-event-queue"; -import { FileOperations } from "./file-operations"; +import { FileOperations, MoveOnConflict } from "./file-operations"; import { Logger } from "../tracing/logger"; import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly"; import type { FileSystemOperations } from "./filesystem-operations"; @@ -20,22 +19,6 @@ class MockServerConfig implements Pick { } } -// The queue only receives `moveDocument`/`removeDocument` from file-ops; for -// these tests we just need no-op implementations that let the type-check -// pass when cast to `SyncEventQueue`. -class MockQueue implements Pick { - public moveDocument( - _oldPath: RelativePath, - _newPath: RelativePath - ): void { - // no-op - } - - public removeDocument(_path: RelativePath): void { - // no-op - } -} - class FakeFileSystemOperations implements FileSystemOperations { public readonly names = new Set(); @@ -62,14 +45,14 @@ class FakeFileSystemOperations implements FileSystemOperations { 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 delete(_path: RelativePath): Promise { - throw new Error("Method not implemented."); + public async createDirectory(_path: RelativePath): Promise { + // no-op for the in-memory fake; we only track files + } + public async delete(path: RelativePath): Promise { + this.names.delete(path); } public async rename( oldPath: RelativePath, @@ -117,19 +100,21 @@ describe("File operations", () => { it("move to empty target just renames the file", async () => { const { fs, ops } = makeOps(); - await ops.create("a", new Uint8Array()); + await ops.create("a", new Uint8Array(), MoveOnConflict.EXISTING); assertSetContainsExactly(fs.names, "a"); - await ops.move("a", "b"); + await ops.move("a", "b", MoveOnConflict.EXISTING); assertSetContainsExactly(fs.names, "b"); }); - it("create at an occupied path displaces the existing file to a conflict-uuid path", async () => { + it("create with EXISTING displaces the existing file to a conflict path", async () => { const { fs, ops } = makeOps(); - await ops.create("note.md", new Uint8Array()); - await ops.create("note.md", new Uint8Array()); + await ops.create("note.md", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.create("note.md", new Uint8Array(), MoveOnConflict.EXISTING); + // The original `note.md` location now holds the new file; the previous + // contents were displaced to a conflict path. const conflict = singleConflictPath(fs.names, ["note.md"]); assert.ok( conflict.endsWith("-note.md"), @@ -137,13 +122,27 @@ describe("File operations", () => { ); }); - it("move to an occupied target displaces the target to a conflict-uuid path", async () => { + it("create with NEW redirects the new file to a conflict path", async () => { const { fs, ops } = makeOps(); - await ops.create("source.md", new Uint8Array()); - await ops.create("dest.md", new Uint8Array()); + await ops.create("note.md", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.create("note.md", new Uint8Array(), MoveOnConflict.NEW); - await ops.move("source.md", "dest.md"); + // The original `note.md` is untouched; the new file went to a conflict path. + const conflict = singleConflictPath(fs.names, ["note.md"]); + assert.ok( + conflict.endsWith("-note.md"), + `conflict name should preserve the original filename, got ${conflict}` + ); + }); + + it("move with EXISTING displaces the target to a conflict path", async () => { + const { fs, ops } = makeOps(); + + await ops.create("source.md", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.create("dest.md", new Uint8Array(), MoveOnConflict.EXISTING); + + await ops.move("source.md", "dest.md", MoveOnConflict.EXISTING); // `dest.md` now holds what used to be at `source.md`; the original // `dest.md` moved to a conflict path in the same directory. @@ -154,12 +153,28 @@ describe("File operations", () => { ); }); + it("move with NEW redirects the moved file to a conflict path", async () => { + const { fs, ops } = makeOps(); + + await ops.create("source.md", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.create("dest.md", new Uint8Array(), MoveOnConflict.EXISTING); + + await ops.move("source.md", "dest.md", MoveOnConflict.NEW); + + // The original `dest.md` is untouched; the moved file went to a conflict path. + const conflict = singleConflictPath(fs.names, ["dest.md"]); + assert.ok( + conflict.endsWith("-dest.md"), + `conflict should preserve the original filename, got ${conflict}` + ); + }); + it("preserves the parent directory when generating a conflict path", async () => { const { fs, ops } = makeOps(); - await ops.create("a/b.c/d", new Uint8Array()); - await ops.create("a/b.c/e", new Uint8Array()); - await ops.move("a/b.c/d", "a/b.c/e"); + await ops.create("a/b.c/d", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.create("a/b.c/e", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.move("a/b.c/d", "a/b.c/e", MoveOnConflict.EXISTING); const conflict = singleConflictPath(fs.names, ["a/b.c/e"]); assert.ok( @@ -175,9 +190,9 @@ describe("File operations", () => { it("handles dotfiles without mangling the extension", async () => { const { fs, ops } = makeOps(); - await ops.create(".gitignore", new Uint8Array()); - await ops.create("temp", new Uint8Array()); - await ops.move("temp", ".gitignore"); + await ops.create(".gitignore", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.create("temp", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.move("temp", ".gitignore", MoveOnConflict.EXISTING); const conflict = singleConflictPath(fs.names, [".gitignore"]); assert.ok( @@ -185,9 +200,9 @@ describe("File operations", () => { `conflict should preserve the dotfile name verbatim, got ${conflict}` ); - await ops.create(".config.json", new Uint8Array()); - await ops.create("temp2", new Uint8Array()); - await ops.move("temp2", ".config.json"); + await ops.create(".config.json", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.create("temp2", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.move("temp2", ".config.json", MoveOnConflict.EXISTING); // Now one conflict for .gitignore, one for .config.json. const conflicts = Array.from(fs.names).filter( @@ -202,9 +217,9 @@ describe("File operations", () => { it("generates a fresh conflict path on every displacement", async () => { const { fs, ops } = makeOps(); - await ops.create("x", new Uint8Array()); - await ops.create("x", new Uint8Array()); - await ops.create("x", new Uint8Array()); + await ops.create("x", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.create("x", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.create("x", new Uint8Array(), MoveOnConflict.EXISTING); const conflicts = Array.from(fs.names).filter((n) => n !== "x"); assert.equal(conflicts.length, 2); diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 9cb4f521..5384768d 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -68,9 +68,6 @@ export class FileOperations { return actualPath; } - /** - * Ensure nothing sits at `path` so the caller can write to it. - */ private async ensureClearPath( path: RelativePath, moveOnConflict: MoveOnConflict @@ -87,7 +84,7 @@ export class FileOperations { ); await this.fs.rename(path, conflictPath); - return conflictPath; + return path; } this.logger.debug(`No existing file at ${path}, creating parent directories if needed`); diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts index ee9967a0..99b37bf8 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts @@ -5,6 +5,7 @@ import { Settings } from "../persistence/settings"; import { Logger } from "../tracing/logger"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; import { SyncEventType } from "./types"; +import type { DocumentRecord, RelativePath } from "./types"; function createQueue(ignorePatterns: string[] = []): SyncEventQueue { const logger = new Logger(); @@ -29,72 +30,47 @@ function fakeRemoteVersion( }; } +function fakeRecord( + documentId: string, + overrides: Partial = {} +): DocumentRecord { + return { + documentId, + parentVersionId: 1, + remoteHash: `hash-${documentId}`, + remoteRelativePath: `${documentId}.md`, + ...overrides + }; +} + describe("SyncEventQueue", () => { - it("sync-local followed by delete for the same document returns only the delete", async () => { + it("returns enqueued events in FIFO order with no coalescing", async () => { const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); + await queue.setDocument("a.md", fakeRecord("A")); - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); - queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "c.md" }); + await queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); - const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.LocalDelete); - if (event?.type === SyncEventType.LocalDelete) { - assert.strictEqual(event.documentId, "A"); + const first = await queue.next(); + assert.strictEqual(first?.type, SyncEventType.LocalCreate); + + const second = await queue.next(); + assert.strictEqual(second?.type, SyncEventType.LocalCreate); + + const third = await queue.next(); + assert.strictEqual(third?.type, SyncEventType.LocalDelete); + if (third?.type === SyncEventType.LocalDelete) { + assert.strictEqual(third.documentId, "A"); } - assert.strictEqual(await queue.next(), undefined); - }); - it("sync-local events for the same document coalesce to one", async () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); - - const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.LocalUpdate); - assert.strictEqual(await queue.next(), undefined); - }); - - it("sync-remote-content events for the same documentId coalesce to the last one", async () => { - const queue = createQueue(); - - queue.enqueue({ - type: SyncEventType.RemoteChange, - remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 1 }) - }); - queue.enqueue({ - type: SyncEventType.RemoteChange, - remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 2 }) - }); - queue.enqueue({ - type: SyncEventType.RemoteChange, - remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 3 }) - }); - - const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.RemoteChange); - if (event?.type === SyncEventType.RemoteChange) { - assert.strictEqual(event.remoteVersion.vaultUpdateId, 3); - } assert.strictEqual(await queue.next(), undefined); }); it("create events are returned FIFO", async () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); const first = await queue.next(); assert.strictEqual(first?.type, SyncEventType.LocalCreate); @@ -111,13 +87,9 @@ describe("SyncEventQueue", () => { it("delete resolves documentId from path", async () => { const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); + await queue.setDocument("a.md", fakeRecord("A")); - queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); + await queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); const event = await queue.next(); assert.strictEqual(event?.type, SyncEventType.LocalDelete); @@ -126,237 +98,71 @@ describe("SyncEventQueue", () => { } }); - it("delete for unknown path is silently ignored", () => { + it("delete for unknown path is silently ignored", async () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.LocalDelete, path: "unknown.md" }); + await queue.enqueue({ type: SyncEventType.LocalDelete, path: "unknown.md" }); assert.strictEqual(queue.pendingUpdateCount, 0); }); - it("document store CRUD operations work correctly", () => { + it("document store CRUD operations work correctly", async () => { const queue = createQueue(); assert.strictEqual(queue.getSettledDocumentByPath("a.md"), undefined); assert.strictEqual(queue.syncedDocumentCount, 0); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); + await queue.setDocument("a.md", fakeRecord("A")); assert.strictEqual(queue.syncedDocumentCount, 1); - assert.deepStrictEqual(queue.getSettledDocumentByPath("a.md"), { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); + assert.deepStrictEqual(queue.getSettledDocumentByPath("a.md"), fakeRecord("A")); const found = queue.getDocumentByDocumentId("A"); assert.strictEqual(found?.path, "a.md"); assert.strictEqual(found?.record.documentId, "A"); - queue.removeDocument("a.md"); + await queue.removeDocument("a.md"); assert.strictEqual(queue.syncedDocumentCount, 0); assert.strictEqual(queue.getSettledDocumentByPath("a.md"), undefined); }); - it("SyncLocal with oldPath moves the document in the store", () => { + it("SyncLocal with oldPath moves the document in the store", async () => { const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); + await queue.setDocument("a.md", fakeRecord("A")); - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "b.md", oldPath: "a.md" }); + await queue.enqueue({ type: SyncEventType.LocalUpdate, path: "b.md", oldPath: "a.md" }); assert.strictEqual(queue.getSettledDocumentByPath("a.md"), undefined); assert.strictEqual(queue.getSettledDocumentByPath("b.md")?.documentId, "A"); }); - it("interleaved events for different documents are not confused", async () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - queue.setDocument("b.md", { - documentId: "B", - parentVersionId: 2, - remoteHash: "hash-b" - }); - - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "b.md" }); - queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "b.md" }); - - // First next() should see the delete for A (coalescing sync-local + delete) - const first = await queue.next(); - assert.strictEqual(first?.type, SyncEventType.LocalDelete); - if (first?.type === SyncEventType.LocalDelete) { - assert.strictEqual(first.documentId, "A"); - } - - // Remaining should be the coalesced sync-local for B - const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.LocalUpdate); - if (second?.type === SyncEventType.LocalUpdate) { - assert.strictEqual(second.documentId, "B"); - } - - assert.strictEqual(await queue.next(), undefined); - }); - - it("delete discards subsequent sync-remote-content events for the same document", async () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - - queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); - queue.enqueue({ - type: SyncEventType.RemoteChange, - remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 5 }) - }); - - const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.LocalDelete); - assert.strictEqual(await queue.next(), undefined); - }); - - it("delete discards subsequent sync-local and sync-remote-content for the same document", async () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - - queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); - queue.enqueue({ - type: SyncEventType.RemoteChange, - remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 5 }) - }); - - const first = await queue.next(); - assert.strictEqual(first?.type, SyncEventType.LocalDelete); - - // Only the unrelated create should remain - const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.LocalCreate); - assert.strictEqual(await queue.next(), undefined); - }); - - it("delete with promise documentId does not discard other events", async () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - - // Create is pending — Delete for same path gets a promise documentId - queue.enqueue({ type: SyncEventType.LocalCreate, path: "unknown.md" }); - queue.enqueue({ type: SyncEventType.LocalDelete, path: "unknown.md" }); - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); - - // Dequeue and resolve the Create - const event = await queue.next(); - assert.ok(event?.type === SyncEventType.LocalCreate); - event.resolvers!.resolve("NEW"); - - await queue.next(); // delete - const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.LocalUpdate); - }); - - it("getCreatePromise returns a promise resolved by the event's resolvers", async () => { - const queue = createQueue(); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - - const promise = queue.getLatestCreatePromise("a.md"); - assert.ok(promise !== undefined); - - // The syncer resolves via event.resolvers after dequeuing - const event = await queue.next(); - assert.ok(event?.type === SyncEventType.LocalCreate); - assert.ok(event.resolvers !== undefined); - event.resolvers.resolve("resolved-id"); - - assert.strictEqual(await promise, "resolved-id"); - }); - - it("rejecting the event's resolvers rejects the create promise", async () => { - const queue = createQueue(); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - - const promise = queue.getLatestCreatePromise("a.md"); - assert.ok(promise !== undefined); - - const event = await queue.next(); - assert.ok(event?.type === SyncEventType.LocalCreate); - assert.ok(event.resolvers !== undefined); - event.resolvers.promise.catch(() => { }); - event.resolvers.reject(new Error("cancelled")); - - await assert.rejects(promise); - }); - - it("clear rejects all pending create promises", async () => { - const queue = createQueue(); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); - - const promiseA = queue.getLatestCreatePromise("a.md"); - const promiseB = queue.getLatestCreatePromise("b.md"); - assert.ok(promiseA !== undefined); - assert.ok(promiseB !== undefined); - - queue.clearPending(); - - await assert.rejects(promiseA); - await assert.rejects(promiseB); - }); - it("create can be re-enqueued after being dequeued", async () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); await queue.next(); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); assert.strictEqual(queue.pendingUpdateCount, 1); }); - it("silently ignores create events matching ignore patterns", () => { + it("silently ignores create events matching ignore patterns", async () => { const queue = createQueue(["*.tmp", ".hidden/**"]); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "scratch.tmp" }); - queue.enqueue({ type: SyncEventType.LocalCreate, path: ".hidden/secret.md" }); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "scratch.tmp" }); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: ".hidden/secret.md" }); assert.strictEqual(queue.pendingUpdateCount, 0); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "notes-new.md" }); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "notes-new.md" }); assert.strictEqual(queue.pendingUpdateCount, 1); - queue.enqueue({ + await queue.enqueue({ type: SyncEventType.RemoteChange, remoteVersion: fakeRemoteVersion("N") }); assert.strictEqual(queue.pendingUpdateCount, 2); }); - it("clear removes events but keeps documents", () => { + it("clearPending removes events but keeps documents", async () => { const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); + await queue.setDocument("a.md", fakeRecord("A")); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "c.md" }); assert.strictEqual(queue.pendingUpdateCount, 2); @@ -367,22 +173,14 @@ describe("SyncEventQueue", () => { assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "A"); }); - it("allDocuments returns all tracked documents", () => { + it("allSettledDocuments returns all tracked documents", async () => { const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - queue.setDocument("b.md", { - documentId: "B", - parentVersionId: 2, - remoteHash: "hash-b" - }); + await queue.setDocument("a.md", fakeRecord("A")); + await queue.setDocument("b.md", fakeRecord("B")); const docs = queue.allSettledDocuments(); - assert.strictEqual(docs.length, 2); - const paths = docs.map(([p]) => p).sort(); + assert.strictEqual(docs.size, 2); + const paths = Array.from(docs.keys()).sort(); assert.deepStrictEqual(paths, ["a.md", "b.md"]); }); @@ -393,15 +191,11 @@ describe("SyncEventQueue", () => { documents: [ { relativePath: "a.md", - documentId: "A", - parentVersionId: 5, - remoteHash: "hash-a" + ...fakeRecord("A", { parentVersionId: 5 }) }, { relativePath: "b.md", - documentId: "B", - parentVersionId: 3, - remoteHash: "hash-b" + ...fakeRecord("B", { parentVersionId: 3 }) } ], lastSeenUpdateId: 4 @@ -410,105 +204,59 @@ describe("SyncEventQueue", () => { assert.strictEqual(queue.syncedDocumentCount, 2); assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "A"); assert.strictEqual(queue.getSettledDocumentByPath("b.md")?.documentId, "B"); - assert.strictEqual(queue._lastSeenUpdateId, 5); + assert.strictEqual(queue.lastSeenUpdateId, 4); }); - it("trackedPaths combines documents and pending events", () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - queue.setDocument("b.md", { - documentId: "B", - parentVersionId: 2, - remoteHash: "hash-b" - }); - - // Pending create adds a path - queue.enqueue({ type: SyncEventType.LocalCreate, path: "c.md" }); - // Pending delete removes a path - queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); - - const paths = queue.trackedPaths(); - assert.deepStrictEqual( - [...paths].sort(), - ["b.md", "c.md"] - ); - }); - - it("trackedPaths handles create-delete-create for the same path", () => { + it("resolveCreate settles the document and resolves the create promise", async () => { const queue = createQueue(); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - // Delete gets promise documentId from pending Create - queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); - queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - - const paths = queue.trackedPaths(); - assert.ok(paths.has("a.md")); - }); - - it("trackedPaths applies moves for pending SyncLocal events", () => { - const queue = createQueue(); - - queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - - // File was renamed from a.md to b.md - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "b.md", oldPath: "a.md" }); - - const paths = queue.trackedPaths(); - assert.ok(!paths.has("a.md")); - assert.ok(paths.has("b.md")); - }); - - it("trackedPaths tracks multiple moves for the same pending create", () => { - const queue = createQueue(); - - queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "b.md", oldPath: "a.md" }); - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "c.md", oldPath: "b.md" }); - - const paths = queue.trackedPaths(); - assert.ok(!paths.has("a.md")); - assert.ok(!paths.has("b.md")); - assert.ok(paths.has("c.md")); - }); - - it("resolveCreate settles the document and replaces promise documentIds in the queue", async () => { - const queue = createQueue(); - - queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - const createPromise = queue.getLatestCreatePromise("a.md")!; - - // Dependent events enqueued while create is still pending - queue.enqueue({ type: SyncEventType.LocalUpdate, path: "a.md" }); - queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); const event = await queue.next(); // dequeue the create assert.ok(event?.type === SyncEventType.LocalCreate); + const createPromise = event.resolvers.promise; - queue.resolveCreate(event, { - documentId: "DOC-1", - parentVersionId: 5, - remoteHash: "hash-1", - }); + await queue.resolveCreate(event, fakeRecord("DOC-1", { parentVersionId: 5 })); // Document is now settled assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "DOC-1"); // Promise was resolved assert.strictEqual(await createPromise, "DOC-1"); + }); - // Remaining events have string documentIds instead of promises. - // The SyncLocal + Delete for "DOC-1" coalesce: sync-local is - // discarded and the delete is returned (standard coalescing). - const deleteEvt = await queue.next(); - assert.ok(deleteEvt?.type === SyncEventType.LocalDelete); - assert.strictEqual(deleteEvt.documentId, "DOC-1"); + it("findLatestCreateForPath returns the pending create", async () => { + const queue = createQueue(); - assert.strictEqual(await queue.next(), undefined); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); + + const found = queue.findLatestCreateForPath("a.md" as RelativePath); + assert.ok(found !== undefined); + assert.strictEqual(found.path, "a.md"); + + const missing = queue.findLatestCreateForPath("c.md" as RelativePath); + assert.strictEqual(missing, undefined); + }); + + it("hasPendingEventsForPath reflects pending events", async () => { + const queue = createQueue(); + await queue.setDocument("a.md", fakeRecord("A")); + + assert.strictEqual(queue.hasPendingEventsForPath("a.md"), false); + + await queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); + assert.strictEqual(queue.hasPendingEventsForPath("a.md"), true); + }); + + it("clearAllState clears everything", async () => { + const queue = createQueue(); + await queue.setDocument("a.md", fakeRecord("A")); + await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); + + await queue.clearAllState(); + + assert.strictEqual(queue.syncedDocumentCount, 0); + assert.strictEqual(queue.pendingUpdateCount, 0); }); }); diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index ba008753..69856e8d 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -136,7 +136,6 @@ export class SyncEventQueue { if (input.oldPath !== undefined) { if (pendingDocumentId !== undefined) { this.updatePendingCreatePath(input.oldPath, path); - this.events.push({ type: SyncEventType.LocalUpdate, documentId: pendingDocumentId, path, originalPath: path }); } else { this.documents.delete(input.oldPath); this.documents.set(path, record!); @@ -146,11 +145,13 @@ export class SyncEventQueue { e.path = path; } } - this.events.push({ type: SyncEventType.LocalUpdate, documentId: documentId!, path, originalPath: path }); await this.save(); } + return } + + this.events.push({ type: SyncEventType.LocalUpdate, documentId: pendingDocumentId ?? documentId!, path, originalPath: path }); } From 7f62273e7288c16ef46192e3ea78935d82efe3c2 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 17:55:46 +0100 Subject: [PATCH 19/52] Format & lint --- .vscode/settings.json | 2 +- frontend/deterministic-tests/README.md | 31 +- frontend/deterministic-tests/src/cli.ts | 260 +++++++++-------- .../src/deterministic-agent.ts | 56 ++-- .../src/managed-websocket.ts | 216 ++++++++------ .../deterministic-tests/src/server-control.ts | 7 +- .../deterministic-tests/src/server-manager.ts | 8 +- .../deterministic-tests/src/test-registry.ts | 56 ++-- .../deterministic-tests/src/test-runner.ts | 15 +- ...-text-pending-create-not-displaced.test.ts | 10 +- ...concurrent-update-diff-consistency.test.ts | 13 +- ...ser-parenthesized-file-not-deleted.test.ts | 3 +- .../src/tests/11-create-delete-noop.test.ts | 8 +- .../src/tests/12-create-merge-delete.test.ts | 14 +- ...3-move-identical-content-ambiguity.test.ts | 3 +- ...reate-update-coalesce-server-pause.test.ts | 10 +- .../16-create-during-reconciliation.test.ts | 3 +- ...ate-merge-preserves-renamed-update.test.ts | 10 +- .../18-create-rename-create-same-path.test.ts | 3 +- .../tests/19-move-chain-three-files.test.ts | 3 +- ...inary-pending-create-not-displaced.test.ts | 14 +- ...sce-update-remote-update-data-loss.test.ts | 9 +- ...esced-remote-update-watermark-loss.test.ts | 22 +- ...urrent-delete-during-remote-update.test.ts | 9 +- ...oncurrent-edit-exact-same-position.test.ts | 3 +- ...urrent-rename-and-create-at-target.test.ts | 9 +- ...urrent-rename-and-create-at-target.test.ts | 3 +- .../9-concurrent-rename-same-target.test.ts | 3 +- .../tests/binary-to-text-transition.test.ts | 66 ++++- .../concurrent-rename-first-wins.test.ts | 44 ++- .../create-rename-response-skips-file.test.ts | 8 +- ...lete-by-other-client-then-recreate.test.ts | 24 +- .../delete-during-pending-create.test.ts | 8 +- .../delete-recreate-concurrent-update.test.ts | 15 +- .../delete-recreate-different-content.test.ts | 12 +- .../tests/delete-recreate-same-path.test.ts | 15 +- .../src/tests/delete-rename-conflict.test.ts | 23 +- .../displaced-file-not-marked-deleted.test.ts | 10 +- .../src/tests/double-offline-cycle.test.ts | 29 +- .../tests/failed-vfs-move-falls-back.test.ts | 8 +- .../idempotency-after-server-pause.test.ts | 15 +- .../tests/interrupted-delete-retry.test.ts | 10 +- .../tests/key-migration-event-drop.test.ts | 8 +- ...ocal-edit-lost-during-create-merge.test.ts | 6 +- ...mc-cross-create-rename-same-target.test.ts | 7 +- .../mc-delete-then-offline-rename.test.ts | 12 +- .../mc-multi-delete-offline-rename.test.ts | 14 +- ...three-client-rename-offline-update.test.ts | 17 +- .../migrate-key-preserves-existing.test.ts | 11 +- .../move-and-concurrent-remote-update.test.ts | 10 +- .../move-preserves-remote-update.test.ts | 27 +- .../move-remote-update-reverts-rename.test.ts | 16 +- .../tests/move-then-delete-stale-path.test.ts | 10 +- .../src/tests/multi-file-operations.test.ts | 14 +- .../tests/offline-concurrent-renames.test.ts | 15 +- ...e-create-same-path-binary-conflict.test.ts | 9 +- .../offline-delete-remote-rename.test.ts | 8 +- .../offline-delete-vs-remote-update.test.ts | 9 +- .../tests/offline-edit-remote-rename.test.ts | 13 +- ...ffline-edit-then-move-same-content.test.ts | 9 +- .../tests/offline-mixed-operations.test.ts | 17 +- .../offline-move-then-remote-delete.test.ts | 9 +- .../src/tests/offline-multiple-edits.test.ts | 10 +- .../src/tests/offline-rename-and-edit.test.ts | 20 +- ...line-rename-remote-create-old-path.test.ts | 15 +- ...ffline-update-both-then-delete-one.test.ts | 21 +- ...e-both-create-same-path-deconflict.test.ts | 3 +- ...te-rename-concurrent-create-orphan.test.ts | 17 +- ...date-while-other-creates-same-path.test.ts | 28 +- ...online-delete-recreate-rapid-cycle.test.ts | 9 +- .../online-edit-vs-delete-convergence.test.ts | 14 +- .../overlapping-edits-same-section.test.ts | 13 +- ...e-reset-loses-coalesced-local-edit.test.ts | 10 +- .../rapid-create-update-delete-cycle.test.ts | 8 +- ...pid-edit-delete-online-convergence.test.ts | 14 +- .../tests/rapid-updates-after-merge.test.ts | 5 +- ...ently-deleted-cleared-on-reconnect.test.ts | 20 +- .../tests/rename-chain-then-delete.test.ts | 12 +- .../src/tests/rename-chain.test.ts | 13 +- .../src/tests/rename-circular.test.ts | 11 +- .../src/tests/rename-create-conflict.test.ts | 10 +- ...ame-pending-create-before-response.test.ts | 9 +- .../src/tests/rename-roundtrip.test.ts | 15 +- .../src/tests/rename-swap.test.ts | 17 +- .../src/tests/rename-to-existing-path.test.ts | 6 +- ...name-to-path-of-unconfirmed-delete.test.ts | 11 +- .../rename-to-pending-path-fallback.test.ts | 23 +- .../rename-to-recently-deleted-path.test.ts | 9 +- .../src/tests/rename-update-conflict.test.ts | 17 +- ...ears-recently-deleted-resurrection.test.ts | 9 +- ...equential-create-duplicate-content.test.ts | 27 +- .../server-pause-both-clients-create.test.ts | 11 +- .../server-pause-both-edit-same-file.test.ts | 20 +- .../server-pause-delete-recreate.test.ts | 14 +- .../server-pause-rename-edit-resume.test.ts | 13 +- .../server-pause-update-and-create.test.ts | 15 +- ...multaneous-create-delete-same-path.test.ts | 18 +- .../three-client-rename-create-delete.test.ts | 10 +- .../update-during-create-processing.test.ts | 9 +- .../update-survives-remote-delete.test.ts | 17 +- .../tests/watermark-advances-on-skip.test.ts | 9 +- ...ark-gap-remote-update-not-recorded.test.ts | 11 +- .../src/utils/assertable-state.ts | 32 +-- frontend/history-ui/src/lib/api.ts | 13 +- frontend/history-ui/src/lib/stores.svelte.ts | 21 +- .../history-ui/src/lib/types/ClientCursors.ts | 6 +- .../src/lib/types/CreateDocumentVersion.ts | 6 +- .../src/lib/types/CursorPositionFromClient.ts | 4 +- .../src/lib/types/CursorPositionFromServer.ts | 2 +- .../history-ui/src/lib/types/CursorSpan.ts | 2 +- .../src/lib/types/DocumentUpdateResponse.ts | 4 +- .../src/lib/types/DocumentVersion.ts | 11 +- .../types/DocumentVersionWithoutContent.ts | 11 +- .../src/lib/types/DocumentWithCursors.ts | 7 +- .../lib/types/FetchLatestDocumentsResponse.ts | 12 +- .../src/lib/types/ListVaultsResponse.ts | 6 +- .../history-ui/src/lib/types/PingResponse.ts | 39 +-- .../src/lib/types/SerializedError.ts | 6 +- .../lib/types/UpdateTextDocumentVersion.ts | 6 +- .../src/lib/types/VaultHistoryResponse.ts | 5 +- .../history-ui/src/lib/types/VaultInfo.ts | 6 +- .../src/lib/types/WebSocketClientMessage.ts | 4 +- .../src/lib/types/WebSocketHandshake.ts | 6 +- .../src/lib/types/WebSocketServerMessage.ts | 4 +- .../src/lib/types/WebSocketVaultUpdate.ts | 2 +- frontend/local-client-cli/src/args.ts | 30 +- frontend/local-client-cli/src/cli.ts | 16 +- frontend/local-client-cli/src/file-watcher.ts | 7 +- .../local-client-cli/src/node-filesystem.ts | 41 +-- frontend/local-client-cli/src/path-utils.ts | 5 +- .../obsidian-plugin/src/vault-link-plugin.ts | 24 +- .../file-operations/file-operations.test.ts | 32 ++- .../src/file-operations/file-operations.ts | 82 +++--- frontend/sync-client/src/index.ts | 6 +- .../src/services/fetch-controller.ts | 18 +- .../sync-client/src/services/sync-service.ts | 93 +++--- .../src/services/types/ClientCursors.ts | 6 +- .../services/types/CreateDocumentVersion.ts | 6 +- .../types/CursorPositionFromClient.ts | 4 +- .../types/CursorPositionFromServer.ts | 4 +- .../src/services/types/CursorSpan.ts | 5 +- .../services/types/DocumentUpdateResponse.ts | 4 +- .../src/services/types/DocumentVersion.ts | 11 +- .../types/DocumentVersionWithoutContent.ts | 11 +- .../src/services/types/DocumentWithCursors.ts | 7 +- .../types/FetchLatestDocumentsResponse.ts | 12 +- .../src/services/types/ListVaultsResponse.ts | 6 +- .../src/services/types/PingResponse.ts | 39 +-- .../src/services/types/SerializedError.ts | 6 +- .../types/UpdateTextDocumentVersion.ts | 6 +- .../services/types/VaultHistoryResponse.ts | 5 +- .../src/services/types/VaultInfo.ts | 6 +- .../services/types/WebSocketClientMessage.ts | 4 +- .../src/services/types/WebSocketHandshake.ts | 6 +- .../services/types/WebSocketServerMessage.ts | 4 +- .../services/types/WebSocketVaultUpdate.ts | 4 +- .../src/services/websocket-manager.ts | 6 +- frontend/sync-client/src/sync-client.ts | 26 +- .../src/sync-operations/conflict-path.test.ts | 15 +- .../src/sync-operations/conflict-path.ts | 15 +- .../src/sync-operations/cursor-tracker.ts | 25 +- .../offline-change-detector.ts | 34 ++- .../sync-operations/sync-event-queue.test.ts | 124 +++++--- .../src/sync-operations/sync-event-queue.ts | 189 +++++++------ .../sync-client/src/sync-operations/syncer.ts | 266 ++++++++++-------- .../sync-client/src/sync-operations/types.ts | 47 ++-- .../src/utils/data-structures/locks.test.ts | 6 +- .../src/utils/data-structures/locks.ts | 11 +- .../src/utils/data-structures/min-covered.ts | 2 +- .../src/utils/debugging/log-to-console.ts | 4 +- .../src/utils/find-matching-file.ts | 8 +- frontend/sync-client/src/utils/hash.ts | 8 +- frontend/sync-client/src/utils/rate-limit.ts | 6 +- frontend/test-client/src/agent/mock-agent.ts | 46 ++- frontend/test-client/src/agent/mock-client.ts | 39 ++- frontend/test-client/src/cli.ts | 9 +- .../src/utils/test-error-tracker.ts | 4 +- package-lock.json | 8 +- sync-server/config-e2e.yml | 32 +-- 179 files changed, 2210 insertions(+), 1319 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index e5963c20..98187650 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,4 +7,4 @@ "**/.sqlx": true, "**/target": true } -} \ No newline at end of file +} diff --git a/frontend/deterministic-tests/README.md b/frontend/deterministic-tests/README.md index 678cd0fe..0fe053f0 100644 --- a/frontend/deterministic-tests/README.md +++ b/frontend/deterministic-tests/README.md @@ -17,20 +17,25 @@ All tests run in parallel up to a concurrency limit. Clients always start with syncing disabled. **File operations** (per-client, fire-and-forget — sync is enqueued but not awaited): + - `create`, `update`, `rename`, `delete` **Sync control:** + - `sync` — wait for a specific client or all clients to finish pending operations - `barrier` — retry until all clients converge to identical file state (60s timeout) - `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:** + - `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process **Assertions:** + - `assert-consistent` — all clients have identical files; optionally takes a custom `verify(state: AssertableState)` callback ## Running @@ -57,15 +62,19 @@ npm run test -w deterministic-tests -- -j 4 import type { TestDefinition } from "../test-definition"; export const myScenarioTest: TestDefinition = { - description: "Client 0 creates A.md offline. After syncing, both clients should have the file.", - clients: 2, - steps: [ - { type: "create", client: 0, path: "A.md", content: "hello" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello") } - ] + description: + "Client 0 creates A.md offline. After syncing, both clients should have the file.", + clients: 2, + steps: [ + { type: "create", client: 0, path: "A.md", content: "hello" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "barrier" }, + { + type: "assert-consistent", + verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello") + } + ] }; ``` @@ -88,7 +97,7 @@ s.ifFileExists("path", (s) => ...) // conditional assertion import { myScenarioTest } from "./tests/my-scenario.test"; const TESTS = { - // ... - "my-scenario": myScenarioTest + // ... + "my-scenario": myScenarioTest }; ``` diff --git a/frontend/deterministic-tests/src/cli.ts b/frontend/deterministic-tests/src/cli.ts index 57cee963..6e0e764f 100644 --- a/frontend/deterministic-tests/src/cli.ts +++ b/frontend/deterministic-tests/src/cli.ts @@ -38,137 +38,6 @@ interface NamedTestResult { result: TestResult; } - -async function main(): Promise { - const cwd = process.cwd(); - let projectRoot = cwd; - - if (cwd.endsWith("frontend/deterministic-tests")) { - projectRoot = path.resolve(cwd, "../.."); - } else if (cwd.endsWith("frontend")) { - projectRoot = path.resolve(cwd, ".."); - } - - const serverPath = path.join(projectRoot, SERVER_BINARY_PATH); - if (!fs.existsSync(serverPath)) { - logger.error(`Server binary not found at: ${serverPath}`); - process.exit(1); - } - - const configPath = path.join(projectRoot, CONFIG_PATH); - if (!fs.existsSync(configPath)) { - logger.error(`Config file not found at: ${configPath}`); - process.exit(1); - } - - const filterArg = process.argv.find((a) => a.startsWith("--filter=")); - const filter = filterArg?.slice("--filter=".length); - - const testsToRun: [string, TestDefinition][] = []; - for (const [key, test] of Object.entries(TESTS)) { - if (test) { - if (filter && !key.includes(filter)) { - continue; - } - testsToRun.push([key, test]); - } - } - - if (testsToRun.length === 0) { - logger.error( - filter - ? `No tests matched filter "${filter}"` - : "No tests found" - ); - process.exit(1); - } - - const concurrency = parseConcurrency(); - const regularTests = testsToRun.filter( - ([, t]) => !testUsesPauseServer(t) - ); - const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t)); - - logger.info(`Server: ${serverPath}`); - logger.info(`Config: ${configPath}`); - logger.info( - `Tests: ${testsToRun.length} total (${regularTests.length} regular, ${pauseTests.length} server-pause)` - ); - logger.info(`Concurrency: ${concurrency}`); - - const allResults: NamedTestResult[] = []; - - if (regularTests.length > 0) { - logger.info( - `\n--- Running ${regularTests.length} regular tests (shared server, concurrency ${concurrency}) ---` - ); - const sharedServer = new ServerControl( - serverPath, - configPath, - logger - ); - serverManager.track(sharedServer); - - try { - await sharedServer.start(); - - const results = await runWithConcurrency( - regularTests, - concurrency, - async ([name, test]) => - runSharedServerTest(name, test, sharedServer) - ); - - allResults.push(...results); - } finally { - try { - await sharedServer.stop(); - } catch (error) { - logger.warn( - `Error stopping shared server: ${error instanceof Error ? error.message : String(error)}` - ); - } - serverManager.untrack(sharedServer); - } - } - - if (pauseTests.length > 0) { - logger.info( - `\n--- Running ${pauseTests.length} server-pause tests (dedicated servers, concurrency ${concurrency}) ---` - ); - - const results = await runWithConcurrency( - pauseTests, - concurrency, - async ([name, test]) => - runDedicatedServerTest(name, test, serverPath, configPath) - ); - - allResults.push(...results); - } - - const passed = allResults.filter((r) => r.result.success); - const failed = allResults.filter((r) => !r.result.success); - - logger.info(`\n--- Results: ${passed.length}/${allResults.length} passed ---`); - - if (failed.length > 0) { - for (const { name, result } of failed) { - logger.error(` FAILED: ${name}: ${result.error}`); - } - process.exit(1); - } else { - logger.info("All tests passed!"); - process.exit(0); - } -} - -main().catch((err: unknown) => { - logger.error(`Unexpected error: ${err}`); - process.exit(1); -}); - - async function runSharedServerTest( name: string, test: TestDefinition, @@ -229,3 +98,132 @@ async function runDedicatedServerTest( serverManager.untrack(server); } } + +async function main(): Promise { + const cwd = process.cwd(); + let projectRoot = cwd; + + if (cwd.endsWith("frontend/deterministic-tests")) { + projectRoot = path.resolve(cwd, "../.."); + } else if (cwd.endsWith("frontend")) { + projectRoot = path.resolve(cwd, ".."); + } + + const serverPath = path.join(projectRoot, SERVER_BINARY_PATH); + if (!fs.existsSync(serverPath)) { + logger.error(`Server binary not found at: ${serverPath}`); + process.exit(1); + } + + const configPath = path.join(projectRoot, CONFIG_PATH); + if (!fs.existsSync(configPath)) { + logger.error(`Config file not found at: ${configPath}`); + process.exit(1); + } + + const filterArg = process.argv.find((a) => a.startsWith("--filter=")); + const filter = filterArg?.slice("--filter=".length); + + const testsToRun: [string, TestDefinition][] = []; + for (const [key, test] of Object.entries(TESTS)) { + if (test) { + if ( + filter !== undefined && + filter.length > 0 && + !key.includes(filter) + ) { + continue; + } + testsToRun.push([key, test]); + } + } + + if (testsToRun.length === 0) { + logger.error( + filter !== undefined && filter.length > 0 + ? `No tests matched filter "${filter}"` + : "No tests found" + ); + process.exit(1); + } + + const concurrency = parseConcurrency(); + const regularTests = testsToRun.filter(([, t]) => !testUsesPauseServer(t)); + const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t)); + + logger.info(`Server: ${serverPath}`); + logger.info(`Config: ${configPath}`); + logger.info( + `Tests: ${testsToRun.length} total (${regularTests.length} regular, ${pauseTests.length} server-pause)` + ); + logger.info(`Concurrency: ${concurrency}`); + + const allResults: NamedTestResult[] = []; + + if (regularTests.length > 0) { + logger.info( + `\n--- Running ${regularTests.length} regular tests (shared server, concurrency ${concurrency}) ---` + ); + const sharedServer = new ServerControl(serverPath, configPath, logger); + serverManager.track(sharedServer); + + try { + await sharedServer.start(); + + const results = await runWithConcurrency( + regularTests, + concurrency, + async ([name, test]) => + runSharedServerTest(name, test, sharedServer) + ); + + allResults.push(...results); + } finally { + try { + await sharedServer.stop(); + } catch (error) { + logger.warn( + `Error stopping shared server: ${error instanceof Error ? error.message : String(error)}` + ); + } + serverManager.untrack(sharedServer); + } + } + + if (pauseTests.length > 0) { + logger.info( + `\n--- Running ${pauseTests.length} server-pause tests (dedicated servers, concurrency ${concurrency}) ---` + ); + + const results = await runWithConcurrency( + pauseTests, + concurrency, + async ([name, test]) => + runDedicatedServerTest(name, test, serverPath, configPath) + ); + + allResults.push(...results); + } + + const passed = allResults.filter((r) => r.result.success); + const failed = allResults.filter((r) => !r.result.success); + + logger.info( + `\n--- Results: ${passed.length}/${allResults.length} passed ---` + ); + + if (failed.length > 0) { + for (const { name, result } of failed) { + logger.error(` FAILED: ${name}: ${result.error}`); + } + process.exit(1); + } else { + logger.info("All tests passed!"); + process.exit(0); + } +} + +main().catch((err: unknown) => { + logger.error(`Unexpected error: ${err}`); + process.exit(1); +}); diff --git a/frontend/deterministic-tests/src/deterministic-agent.ts b/frontend/deterministic-tests/src/deterministic-agent.ts index 71f6a272..f253186a 100644 --- a/frontend/deterministic-tests/src/deterministic-agent.ts +++ b/frontend/deterministic-tests/src/deterministic-agent.ts @@ -1,13 +1,21 @@ -import type { StoredDatabase, SyncSettings, RelativePath, TextWithCursors } from "sync-client"; -import { SyncClient, debugging, LogLevel } from "sync-client"; +import type { + StoredDatabase, + SyncSettings, + RelativePath, + TextWithCursors +} from "sync-client"; +import { SyncClient, debugging, LogLevel, utils } from "sync-client"; import { assert } from "./utils/assert"; import { sleep } from "./utils/sleep"; import { withTimeout } from "./utils/with-timeout"; -import { IS_SYNC_ENABLED_BY_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"; - - export class DeterministicAgent extends debugging.InMemoryFileSystem { public readonly clientId: number; private readonly logger: (msg: string) => void; @@ -33,7 +41,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { } public async init( - fetchImplementation: typeof globalThis.fetch, + fetchImplementation: typeof globalThis.fetch ): Promise { this.client = await SyncClient.create({ fs: this, @@ -138,7 +146,6 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { await this.waitForWebSocket(); } - public async getFileContent(path: string): Promise { const bytes = await this.read(path); return new TextDecoder().decode(bytes); @@ -146,9 +153,11 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { public async cleanup(): Promise { this.log("Cleaning up..."); - // Guard against uninitialized client (init() failed partway) - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!this.client) { + // Guard against uninitialized client (init() failed partway). + // The class field uses `!:` so TS thinks this is always defined, + // but at runtime it can be undefined when init() throws partway. + const maybeClient = this.client as SyncClient | undefined; + if (maybeClient === undefined) { this.log("Client not initialized, nothing to clean up"); return; } @@ -184,11 +193,13 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { await super.write(path, content); if (isNew) { - this.enqueueSync(async () => { this.client.syncLocallyCreatedFile(path); } - ); + this.enqueueSync(async () => { + this.client.syncLocallyCreatedFile(path); + }); } else { - this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); } - ); + this.enqueueSync(async () => { + this.client.syncLocallyUpdatedFile({ relativePath: path }); + }); } } @@ -197,18 +208,18 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { updater: (current: TextWithCursors) => TextWithCursors ): Promise { const result = await super.atomicUpdateText(path, updater); - this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); } - ); + this.enqueueSync(async () => { + this.client.syncLocallyUpdatedFile({ relativePath: path }); + }); return result; - } - public override async delete(path: RelativePath): Promise { await super.delete(path); if (this.isSyncEnabled) { - this.enqueueSync(async () => { this.client.syncLocallyDeletedFile(path); } - ); + this.enqueueSync(async () => { + this.client.syncLocallyDeletedFile(path); + }); } } @@ -222,8 +233,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { oldPath, relativePath: newPath }); - } - ); + }); } private async waitForWebSocket(): Promise { @@ -243,7 +253,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { */ private async drainPendingSyncOperations(): Promise { while (this.pendingSyncOperations.size > 0) { - await Promise.all(this.pendingSyncOperations); + await utils.awaitAll([...this.pendingSyncOperations]); } } diff --git a/frontend/deterministic-tests/src/managed-websocket.ts b/frontend/deterministic-tests/src/managed-websocket.ts index c09b44d7..c97a43a0 100644 --- a/frontend/deterministic-tests/src/managed-websocket.ts +++ b/frontend/deterministic-tests/src/managed-websocket.ts @@ -2,16 +2,129 @@ * 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. + * + * Member layout follows typescript-eslint default member-ordering: all + * accessor properties are declared with `declare` and wired through the + * constructor using Object.defineProperty so we don't need conflicting + * get/set accessor pairs. */ export class ManagedWebSocket implements WebSocket { + public static readonly CONNECTING = WebSocket.CONNECTING; + public static readonly OPEN = WebSocket.OPEN; + public static readonly CLOSING = WebSocket.CLOSING; + public static readonly CLOSED = WebSocket.CLOSED; + + public readonly CONNECTING = WebSocket.CONNECTING; + public readonly OPEN = WebSocket.OPEN; + public readonly CLOSING = WebSocket.CLOSING; + public readonly CLOSED = WebSocket.CLOSED; + + declare public readonly readyState: number; + declare public readonly url: string; + declare public readonly protocol: string; + declare public readonly extensions: string; + declare public readonly bufferedAmount: number; + declare public binaryType: BinaryType; + declare public onopen: ((this: WebSocket, ev: Event) => unknown) | null; + declare public onclose: + | ((this: WebSocket, ev: CloseEvent) => unknown) + | null; + declare public onerror: ((this: WebSocket, ev: Event) => unknown) | null; + declare public onmessage: + | ((this: WebSocket, ev: MessageEvent) => unknown) + | null; + private readonly ws: WebSocket; - private paused = false; private readonly bufferedMessages: MessageEvent[] = []; + private paused = false; private externalOnMessage: ((event: MessageEvent) => unknown) | null = null; public constructor(url: string | URL, protocols?: string | string[]) { this.ws = new WebSocket(url, protocols); + const { ws } = this; + Object.defineProperties(this, { + readyState: { + get: (): number => ws.readyState, + enumerable: true, + configurable: true + }, + url: { + get: (): string => ws.url, + enumerable: true, + configurable: true + }, + protocol: { + get: (): string => ws.protocol, + enumerable: true, + configurable: true + }, + extensions: { + get: (): string => ws.extensions, + enumerable: true, + configurable: true + }, + bufferedAmount: { + get: (): number => ws.bufferedAmount, + enumerable: true, + configurable: true + }, + binaryType: { + get: (): BinaryType => ws.binaryType, + set: (v: BinaryType): void => { + ws.binaryType = v; + }, + enumerable: true, + configurable: true + }, + onopen: { + get: (): ((this: WebSocket, ev: Event) => unknown) | null => + ws.onopen, + set: ( + h: ((this: WebSocket, ev: Event) => unknown) | null + ): void => { + ws.onopen = h; + }, + enumerable: true, + configurable: true + }, + onclose: { + get: (): + | ((this: WebSocket, ev: CloseEvent) => unknown) + | null => ws.onclose, + set: ( + h: ((this: WebSocket, ev: CloseEvent) => unknown) | null + ): void => { + ws.onclose = h; + }, + enumerable: true, + configurable: true + }, + onerror: { + get: (): ((this: WebSocket, ev: Event) => unknown) | null => + ws.onerror, + set: ( + h: ((this: WebSocket, ev: Event) => unknown) | null + ): void => { + ws.onerror = h; + }, + enumerable: true, + configurable: true + }, + onmessage: { + get: (): + | ((this: WebSocket, ev: MessageEvent) => unknown) + | null => this.externalOnMessage, + set: ( + h: ((this: WebSocket, ev: MessageEvent) => unknown) | null + ): void => { + this.externalOnMessage = h; + }, + enumerable: true, + configurable: true + } + }); + this.ws.onmessage = (event: MessageEvent): void => { if (this.paused) { this.bufferedMessages.push(event); @@ -33,68 +146,6 @@ export class ManagedWebSocket implements WebSocket { } } - 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); } @@ -118,16 +169,6 @@ export class ManagedWebSocket implements WebSocket { 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; } /** @@ -138,22 +179,19 @@ 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; + const trackInstance = (instance: ManagedWebSocket): void => { + this.instances.push(instance); + }; + class TrackedManagedWebSocket extends ManagedWebSocket { + public constructor( + url: string | URL, + protocols?: string | string[] + ) { + super(url, protocols); + trackInstance(this); + } + } + return TrackedManagedWebSocket; } public pause(): void { diff --git a/frontend/deterministic-tests/src/server-control.ts b/frontend/deterministic-tests/src/server-control.ts index de0dbe4b..f903cc4c 100644 --- a/frontend/deterministic-tests/src/server-control.ts +++ b/frontend/deterministic-tests/src/server-control.ts @@ -42,9 +42,7 @@ export class ServerControl { 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( - path.join(tmpBase, "vault-link-test-") - ); + this.tempDir = fs.mkdtempSync(path.join(tmpBase, "vault-link-test-")); const tempConfigPath = path.join(this.tempDir, "config.yml"); const dbDir = path.join(this.tempDir, "databases"); @@ -225,7 +223,7 @@ export class ServerControl { } private cleanupTempDir(): void { - if (this.tempDir) { + if (this.tempDir !== undefined) { try { fs.rmSync(this.tempDir, { recursive: true, force: true }); } catch { @@ -234,5 +232,4 @@ export class ServerControl { this.tempDir = undefined; } } - } diff --git a/frontend/deterministic-tests/src/server-manager.ts b/frontend/deterministic-tests/src/server-manager.ts index 8764e669..e9ca3d57 100644 --- a/frontend/deterministic-tests/src/server-manager.ts +++ b/frontend/deterministic-tests/src/server-manager.ts @@ -39,14 +39,18 @@ export class ServerManager { process.on("SIGINT", () => { this.logger.info("Received SIGINT, shutting down..."); void this.stopAll() - .catch(() => {}) + .catch(() => { + /* no-op */ + }) .then(() => process.exit(130)); }); process.on("SIGTERM", () => { this.logger.info("Received SIGTERM, shutting down..."); void this.stopAll() - .catch(() => {}) + .catch(() => { + /* no-op */ + }) .then(() => process.exit(143)); }); } diff --git a/frontend/deterministic-tests/src/test-registry.ts b/frontend/deterministic-tests/src/test-registry.ts index 4a16db2b..36089335 100644 --- a/frontend/deterministic-tests/src/test-registry.ts +++ b/frontend/deterministic-tests/src/test-registry.ts @@ -102,10 +102,12 @@ export const TESTS: Partial> = { "delete-recreate-same-path": deleteRecreateSamePathTest, "offline-rename-and-edit": offlineRenameAndEditTest, "rename-to-existing-path": renameToExistingPathTest, - "simultaneous-create-delete-same-path": simultaneousCreateDeleteSamePathTest, + "simultaneous-create-delete-same-path": + simultaneousCreateDeleteSamePathTest, "idempotency-after-server-pause": idempotencyAfterServerPauseTest, "sequential-create-duplicate-content": sequentialCreateDuplicateContentTest, - "mc-three-client-rename-offline-update": mcThreeClientRenameOfflineUpdateTest, + "mc-three-client-rename-offline-update": + mcThreeClientRenameOfflineUpdateTest, "mc-multi-delete-offline-rename": mcMultiDeleteOfflineRenameTest, "mc-cross-create-rename-same-target": mcCrossCreateRenameSameTargetTest, "mc-delete-then-offline-rename": mcDeleteThenOfflineRenameTest, @@ -117,7 +119,8 @@ export const TESTS: Partial> = { "rename-swap": renameSwapTest, "rename-circular": renameCircularTest, "rename-roundtrip": renameRoundtripTest, - "offline-rename-remote-create-old-path": offlineRenameRemoteCreateOldPathTest, + "offline-rename-remote-create-old-path": + offlineRenameRemoteCreateOldPathTest, "offline-edit-remote-rename": offlineEditRemoteRenameTest, "rename-chain-then-delete": renameChainThenDeleteTest, "offline-delete-remote-rename": offlineDeleteRemoteRenameTest, @@ -140,34 +143,45 @@ export const TESTS: Partial> = { "delete-recreate-different-content": deleteRecreateDifferentContentTest, "update-during-create-processing": updateDuringCreateProcessingTest, "offline-move-then-remote-delete": offlineMoveThenRemoteDeleteTest, - "reset-clears-recently-deleted-resurrection": resetClearsRecentlyDeletedResurrectionTest, + "reset-clears-recently-deleted-resurrection": + resetClearsRecentlyDeletedResurrectionTest, "move-then-delete-stale-path": moveThenDeleteStalePathTest, "offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest, "interrupted-delete-retry": interruptedDeleteRetryTest, "update-survives-remote-delete": updateDoesNotSurvivesRemoteDeleteTest, "move-preserves-remote-update": movePreservesRemoteUpdateTest, - "recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest, + "recently-deleted-cleared-on-reconnect": + recentlyDeletedClearedOnReconnectTest, "migrate-key-preserves-existing": migrateKeyPreservesExistingTest, "failed-vfs-move-falls-back": failedVfsMoveFallsBackTest, "watermark-advances-on-skip": watermarkAdvancesOnSkipTest, - "watermark-gap-remote-update-not-recorded": watermarkGapRemoteUpdateNotRecordedTest, - "queue-reset-loses-coalesced-local-edit": queueResetLosesCoalescedLocalEditTest, + "watermark-gap-remote-update-not-recorded": + watermarkGapRemoteUpdateNotRecordedTest, + "queue-reset-loses-coalesced-local-edit": + queueResetLosesCoalescedLocalEditTest, "rename-to-pending-path-fallback": renameToPendingPathFallbackTest, "move-remote-update-reverts-rename": moveRemoteUpdateRevertsRenameTest, "local-edit-lost-during-create-merge": localEditLostDuringCreateMergeTest, - "rename-pending-create-before-response": renamePendingCreateBeforeResponseTest, + "rename-pending-create-before-response": + renamePendingCreateBeforeResponseTest, "create-rename-response-skips-file": createRenameResponseSkipsFileTest, - "online-create-rename-concurrent-create-orphan": onlineCreateRenameConcurrentCreateOrphanTest, + "online-create-rename-concurrent-create-orphan": + onlineCreateRenameConcurrentCreateOrphanTest, "concurrent-rename-first-wins": concurrentRenameFirstWinsTest, "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, + "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-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, @@ -176,15 +190,19 @@ export const TESTS: Partial> = { "move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest, "create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest, "create-during-reconciliation": createDuringReconciliationTest, - "create-merge-preserves-renamed-update": createMergePreservesRenamedUpdateTest, + "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, + "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, + "online-both-create-same-path-deconflict": + onlineBothCreateSamePathDeconflictTest, + "online-create-update-while-other-creates-same-path": + onlineCreateUpdateWhileOtherCreatesSamePathTest, + "displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest }; diff --git a/frontend/deterministic-tests/src/test-runner.ts b/frontend/deterministic-tests/src/test-runner.ts index 2d469fa2..8fdefcbe 100644 --- a/frontend/deterministic-tests/src/test-runner.ts +++ b/frontend/deterministic-tests/src/test-runner.ts @@ -1,8 +1,4 @@ -import type { - TestDefinition, - TestResult, - TestStep -} from "./test-definition"; +import type { TestDefinition, TestResult, TestStep } from "./test-definition"; import { DeterministicAgent } from "./deterministic-agent"; import type { ServerControl } from "./server-control"; import type { SyncSettings, Logger } from "sync-client"; @@ -113,9 +109,7 @@ export class TestRunner { // Push before init so cleanup() handles this agent if init fails this.agents.push(agent); await withTimeout( - agent.init( - fetch, - ), + agent.init(fetch), AGENT_INIT_TIMEOUT_MS, `Client ${i} init timed out after ${AGENT_INIT_TIMEOUT_MS}ms` ); @@ -276,7 +270,10 @@ export class TestRunner { verify?: (state: AssertableState) => void ): Promise { this.logger.info("Asserting all clients are consistent..."); - assert(this.agents.length >= 2, "Need at least 2 agents for consistency check"); + assert( + this.agents.length >= 2, + "Need at least 2 agents for consistency check" + ); // Snapshot all agents' file states upfront to minimize the window // where background sync could mutate state between reads. diff --git a/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts index fced7c5f..28243525 100644 --- a/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts +++ b/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const textPendingCreateNotDisplacedTest: TestDefinition = { @@ -23,6 +24,13 @@ export const textPendingCreateNotDisplacedTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileExists("data.txt").assertAnyFileContains("client-0", "client-1") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1) + .assertFileExists("data.txt") + .assertAnyFileContains("client-0", "client-1"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts b/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts index 94e6914e..d21ce16b 100644 --- a/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts +++ b/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const concurrentUpdateDiffConsistencyTest: TestDefinition = { @@ -35,6 +36,16 @@ export const concurrentUpdateDiffConsistencyTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (state) => state.assertFileCount(1).assertContent("doc.md", "header by 0\nmiddle\nfooter by 1") } + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertContent( + "doc.md", + "header by 0\nmiddle\nfooter by 1" + ); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts b/frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts index 8be438e2..ef6cd771 100644 --- a/frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts +++ b/frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const userParenthesizedFileNotDeletedTest: TestDefinition = { @@ -34,7 +35,7 @@ export const userParenthesizedFileNotDeletedTest: TestDefinition = { { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileCount(3) .assertFileExists("Chapter.bin") diff --git a/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts b/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts index b1239217..6c766001 100644 --- a/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts +++ b/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const createDeleteNoopTest: TestDefinition = { @@ -16,6 +17,11 @@ export const createDeleteNoopTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileNotExists("temp.md") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("temp.md"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts b/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts index 4b121939..ef7ea5c3 100644 --- a/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const createMergeDeleteTest: TestDefinition = { @@ -16,12 +17,21 @@ export const createMergeDeleteTest: TestDefinition = { { type: "assert-consistent", - verify: (state) => state.assertFileCount(1).assertContains("A.md", "from-zero", "from-one") + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertContains("A.md", "from-zero", "from-one"); + } }, { type: "delete", client: 0, path: "A.md" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("A.md") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0).assertFileNotExists("A.md"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts b/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts index 9c0f7245..2a9ce0b4 100644 --- a/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts +++ b/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const moveIdenticalContentAmbiguityTest: TestDefinition = { @@ -31,7 +32,7 @@ export const moveIdenticalContentAmbiguityTest: TestDefinition = { { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileCount(1) .assertFileNotExists("A.md") diff --git a/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts b/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts index 608f845d..9b752d05 100644 --- a/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts +++ b/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const createUpdateCoalesceServerPauseTest: TestDefinition = { @@ -19,6 +20,13 @@ export const createUpdateCoalesceServerPauseTest: TestDefinition = { { type: "barrier" }, - { type: "assert-consistent", verify: (state) => state.assertFileCount(1).assertContent("doc.md", "final version") } + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(1) + .assertContent("doc.md", "final version"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts b/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts index 54dc3f98..0fe51106 100644 --- a/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts +++ b/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const createDuringReconciliationTest: TestDefinition = { @@ -37,7 +38,7 @@ export const createDuringReconciliationTest: TestDefinition = { { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileCount(3) .assertContent("A.md", "offline A") diff --git a/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts b/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts index f600c40e..f2b6ba62 100644 --- a/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts +++ b/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const createMergePreservesRenamedUpdateTest: TestDefinition = { @@ -39,6 +40,13 @@ export const createMergePreservesRenamedUpdateTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (state) => state.assertContent("moved.md", "alpha beta extra-update").assertContent("doc.md", "new-content") } + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertContent("moved.md", "alpha beta extra-update") + .assertContent("doc.md", "new-content"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts b/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts index 2b169a1d..dda80042 100644 --- a/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const createRenameCreateSamePathTest: TestDefinition = { @@ -22,7 +23,7 @@ export const createRenameCreateSamePathTest: TestDefinition = { { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileCount(3) .assertContent("B.md", "first file") diff --git a/frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts b/frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts index a6c6851b..fe9267d4 100644 --- a/frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts +++ b/frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const moveChainThreeFilesTest: TestDefinition = { @@ -29,7 +30,7 @@ export const moveChainThreeFilesTest: TestDefinition = { { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileCount(3) .assertContent("A.md", "was C") diff --git a/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts index 0616136b..467c19f0 100644 --- a/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts +++ b/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const binaryPendingCreateNotDisplacedTest: TestDefinition = { @@ -23,6 +24,17 @@ export const binaryPendingCreateNotDisplacedTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(2).assertFileExists("data.bin").assertFileExists("data (1).bin").assertAnyFileContains("binary data from client 0", "binary data from client 1") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(2) + .assertFileExists("data.bin") + .assertFileExists("data (1).bin") + .assertAnyFileContains( + "binary data from client 0", + "binary data from client 1" + ); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts b/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts index 33fb8107..69a5ff10 100644 --- a/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts +++ b/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = { @@ -38,10 +39,14 @@ export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = { { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileCount(1) - .assertContains("doc.md", "client 0 addition", "client 1 addition"); + .assertContains( + "doc.md", + "client 0 addition", + "client 1 addition" + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts b/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts index 15fe3e82..8b1cd242 100644 --- a/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts +++ b/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { @@ -18,7 +19,12 @@ export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { { type: "sync", client: 0 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "final update"); + } + }, { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, @@ -26,13 +32,23 @@ export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "final update"); + } + }, { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "final update") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "final update"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts b/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts index 3108ecfe..88376f22 100644 --- a/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = { @@ -21,7 +22,11 @@ export const concurrentDeleteDuringRemoteUpdateTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (state) => state.assertFileCount(0) } + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertFileCount(0); + } + } ] }; - diff --git a/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts b/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts index 08778488..5c141a0e 100644 --- a/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts +++ b/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const concurrentEditExactSamePositionTest: TestDefinition = { @@ -38,7 +39,7 @@ export const concurrentEditExactSamePositionTest: TestDefinition = { { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileCount(1) .assertContains("doc.md", "slow", "fast", "brown fox"); diff --git a/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts b/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts index 3e71ed7d..c69e391c 100644 --- a/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts +++ b/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { @@ -37,10 +38,14 @@ export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileNotExists("X.md") - .assertContains("Y.md", "original file X", "brand new Y content"); + .assertContains( + "Y.md", + "original file X", + "brand new Y content" + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts b/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts index 9f0b0318..a6f34102 100644 --- a/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts +++ b/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { @@ -37,7 +38,7 @@ export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileCount(2) .assertContains("Y (1).md", "original file X") diff --git a/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts index 230c7a1d..eff10952 100644 --- a/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts +++ b/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const concurrentRenameSameTargetTest: TestDefinition = { @@ -25,7 +26,7 @@ export const concurrentRenameSameTargetTest: TestDefinition = { { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileCount(2) .assertFileNotExists("A.md") diff --git a/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts b/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts index f6e14152..8b934c1b 100644 --- a/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts +++ b/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const binaryToTextTransitionTest: TestDefinition = { @@ -8,11 +9,21 @@ export const binaryToTextTransitionTest: TestDefinition = { "offline. The text merge should preserve both edits.", clients: 2, steps: [ - { type: "create", client: 0, path: "data.bin", content: "original content" }, + { + type: "create", + client: 0, + path: "data.bin", + content: "original content" + }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertContent("data.bin", "original content") }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("data.bin", "original content"); + } + }, { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, @@ -24,26 +35,63 @@ export const binaryToTextTransitionTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContainsAny("data.bin", "version A", "version B") }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContainsAny( + "data.bin", + "version A", + "version B" + ); + } + }, { type: "disable-sync", client: 1 }, { 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: "update", + client: 0, + path: "data.md", + content: "top line\nmiddle line\nbottom line" + }, { type: "sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertContent("data.md", "top line\nmiddle line\nbottom line") }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent( + "data.md", + "top line\nmiddle line\nbottom line" + ); + } + }, { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, - { type: "update", client: 0, path: "data.md", content: "alpha\nmiddle line\nbottom line" }, - { type: "update", client: 1, path: "data.md", content: "top line\nmiddle line\nbeta" }, + { + type: "update", + client: 0, + path: "data.md", + content: "alpha\nmiddle line\nbottom line" + }, + { + type: "update", + client: 1, + path: "data.md", + content: "top line\nmiddle line\nbeta" + }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("data.md", "alpha", "beta") }, - ], + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains("data.md", "alpha", "beta"); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts index 1dddcf7a..aef7688d 100644 --- a/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const concurrentRenameFirstWinsTest: TestDefinition = { @@ -8,29 +9,52 @@ export const concurrentRenameFirstWinsTest: TestDefinition = { "edits are merged.", clients: 2, steps: [ - { type: "create", client: 0, path: "A.md", content: "line 1\nline 2\nline 3" }, + { + type: "create", + client: 0, + path: "A.md", + content: "line 1\nline 2\nline 3" + }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertContent("A.md", "line 1\nline 2\nline 3") }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "line 1\nline 2\nline 3"); + } + }, { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - { type: "update", client: 0, path: "B.md", content: "edit from 0\nline 2\nline 3" }, + { + type: "update", + client: 0, + path: "B.md", + content: "edit from 0\nline 2\nline 3" + }, { type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" }, - { type: "update", client: 1, path: "C.md", content: "line 1\nline 2\nedit from 1" }, + { + type: "update", + client: 1, + path: "C.md", + content: "line 1\nline 2\nedit from 1" + }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => { - s.assertFileNotExists("A.md"); - s.assertFileCount(1); - s.assertAnyFileContains("edit from 0", "edit from 1"); - } }, - ], + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md"); + s.assertFileCount(1); + s.assertAnyFileContains("edit from 0", "edit from 1"); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts b/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts index 5bec2bcb..20d9e621 100644 --- a/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts +++ b/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const createRenameResponseSkipsFileTest: TestDefinition = { @@ -29,6 +30,11 @@ export const createRenameResponseSkipsFileTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertAnyFileContains("the-content") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertAnyFileContains("the-content"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts b/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts index 204e9896..dfef9961 100644 --- a/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-by-other-client-then-recreate.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const deleteByOtherClientThenRecreateTest: TestDefinition = { @@ -14,11 +15,26 @@ export const deleteByOtherClientThenRecreateTest: TestDefinition = { { type: "delete", client: 1, path: "A.md" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileNotExists("A.md") }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md"); + } + }, - { type: "create", client: 0, path: "A.md", content: "recreated by client 0" }, + { + type: "create", + client: 0, + path: "A.md", + content: "recreated by client 0" + }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertContent("A.md", "recreated by client 0") }, - ], + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "recreated by client 0"); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts b/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts index f6236060..831c2f05 100644 --- a/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const deleteDuringPendingCreateTest: TestDefinition = { @@ -26,6 +27,11 @@ export const deleteDuringPendingCreateTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("ephemeral.md") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0).assertFileNotExists("ephemeral.md"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts index c95c6aa4..0d4bcffb 100644 --- a/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const deleteRecreateConcurrentUpdateTest: TestDefinition = { @@ -14,7 +15,12 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = { { type: "disable-sync", client: 0 }, { type: "delete", client: 0, path: "A.md" }, - { type: "create", client: 0, path: "A.md", content: "recreated by client 0" }, + { + type: "create", + client: 0, + path: "A.md", + content: "recreated by client 0" + }, { type: "update", @@ -28,6 +34,11 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileExists("A.md").assertContains("A.md", "recreated") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileExists("A.md").assertContains("A.md", "recreated"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts index 02197b8d..7ecd21a3 100644 --- a/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const deleteRecreateDifferentContentTest: TestDefinition = { @@ -41,6 +42,15 @@ export const deleteRecreateDifferentContentTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "brand new", "client 1") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "A.md", + "brand new", + "client 1" + ); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts index 10b00f70..4b2a836b 100644 --- a/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const deleteRecreateSamePathTest: TestDefinition = { @@ -11,7 +12,12 @@ export const deleteRecreateSamePathTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertContent("A.md", "version 1") }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "version 1"); + } + }, { type: "disable-sync", client: 0 }, { type: "delete", client: 0, path: "A.md" }, @@ -20,6 +26,11 @@ export const deleteRecreateSamePathTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertContent("A.md", "version 2") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("A.md", "version 2"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts b/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts index 4cbeed25..7eeb80ad 100644 --- a/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const deleteRenameConflictTest: TestDefinition = { @@ -12,7 +13,12 @@ export const deleteRenameConflictTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileExists("A.md").assertFileExists("B.md") }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileExists("A.md").assertFileExists("B.md"); + } + }, { type: "disable-sync", client: 1 }, @@ -25,10 +31,15 @@ export const deleteRenameConflictTest: TestDefinition = { { type: "sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => { - s.assertContent("B.md", "content-b"); - s.assertFileNotExists("A.md"); - s.ifFileExists("C.md", (s) => s.assertContent("C.md", "content-a")); - } }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("B.md", "content-b"); + s.assertFileNotExists("A.md"); + s.ifFileExists("C.md", (inner) => + inner.assertContent("C.md", "content-a") + ); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts b/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts index 99d5f716..326343af 100644 --- a/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts +++ b/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const displacedFileNotMarkedDeletedTest: TestDefinition = { @@ -20,14 +21,19 @@ export const displacedFileNotMarkedDeletedTest: TestDefinition = { { 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: "update", + client: 1, + path: "B.md", + content: "edited A content" + }, { type: "enable-sync", client: 1 }, { type: "barrier" }, { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileNotExists("A.md") .assertFileExists("B.md") diff --git a/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts b/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts index 1034ce27..f617ca5f 100644 --- a/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts +++ b/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const doubleOfflineCycleTest: TestDefinition = { @@ -16,7 +17,12 @@ export const doubleOfflineCycleTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "initial") }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("doc.md", "initial"); + } + }, { type: "disable-sync", client: 0 }, { @@ -29,7 +35,12 @@ export const doubleOfflineCycleTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "first edit") }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("doc.md", "first edit"); + } + }, { type: "disable-sync", client: 0 }, { @@ -42,7 +53,12 @@ export const doubleOfflineCycleTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertContent("doc.md", "second edit") }, + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertContent("doc.md", "second edit"); + } + }, { type: "disable-sync", client: 0 }, { @@ -55,6 +71,11 @@ export const doubleOfflineCycleTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "third edit") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "third edit"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts b/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts index f9ae2a3f..b0512617 100644 --- a/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts +++ b/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const failedVfsMoveFallsBackTest: TestDefinition = { @@ -17,6 +18,11 @@ export const failedVfsMoveFallsBackTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("B.md", "content A") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("B.md", "content A"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts b/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts index ce12df0c..58c57511 100644 --- a/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts +++ b/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const idempotencyAfterServerPauseTest: TestDefinition = { @@ -11,7 +12,12 @@ export const idempotencyAfterServerPauseTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "create", client: 0, path: "doc.md", content: "important data" }, + { + type: "create", + client: 0, + path: "doc.md", + content: "important data" + }, { type: "pause-server" }, { type: "resume-server" }, @@ -19,6 +25,11 @@ export const idempotencyAfterServerPauseTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("doc.md", "important data") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "important data"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts index ef8404fb..444adc56 100644 --- a/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts +++ b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const interruptedDeleteRetryTest: TestDefinition = { @@ -20,6 +21,11 @@ export const interruptedDeleteRetryTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(0) }, - ], + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts b/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts index 9d9a870d..f29fa45b 100644 --- a/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts +++ b/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const keyMigrationEventDropTest: TestDefinition = { @@ -30,6 +31,11 @@ export const keyMigrationEventDropTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContent("A.md", "updated content") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("A.md", "updated content"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts b/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts index 66c832db..20925889 100644 --- a/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts +++ b/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const localEditLostDuringCreateMergeTest: TestDefinition = { @@ -28,12 +29,13 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => + verify: (s: AssertableState): void => { s.assertFileCount(1).assertContains( "doc.md", "from-client-1", "local-edit-during-create" - ), + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts index ce991df3..b0175b37 100644 --- a/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const mcCrossCreateRenameSameTargetTest: TestDefinition = { @@ -17,7 +18,9 @@ export const mcCrossCreateRenameSameTargetTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => s.assertFileExists("X.md").assertFileExists("Y.md") + verify: (s: AssertableState): void => { + s.assertFileExists("X.md").assertFileExists("Y.md"); + } }, { type: "disable-sync", client: 1 }, @@ -33,7 +36,7 @@ export const mcCrossCreateRenameSameTargetTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => { + verify: (s: AssertableState): void => { s.assertFileCount(2) .assertFileNotExists("X.md") .assertFileNotExists("Y.md") diff --git a/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts b/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts index 98504f03..0808c65a 100644 --- a/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const mcDeleteThenOfflineRenameTest: TestDefinition = { @@ -27,10 +28,13 @@ export const mcDeleteThenOfflineRenameTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => { - s.assertContent("C.md", "unrelated") - .assertFileNotExists("A.md"); - s.ifFileExists("B.md", (s) => s.assertContent("B.md", "original")); + verify: (s: AssertableState): void => { + s.assertContent("C.md", "unrelated").assertFileNotExists( + "A.md" + ); + s.ifFileExists("B.md", (inner) => + inner.assertContent("B.md", "original") + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts b/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts index 26a095d5..1dbb3464 100644 --- a/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const mcMultiDeleteOfflineRenameTest: TestDefinition = { @@ -22,7 +23,12 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = { { type: "delete", client: 1, path: "file-4.md" }, { type: "sync", client: 1 }, - { type: "rename", client: 0, oldPath: "file-2.md", newPath: "renamed.md" }, + { + type: "rename", + client: 0, + oldPath: "file-2.md", + newPath: "renamed.md" + }, { type: "enable-sync", client: 0 }, { type: "sync" }, @@ -30,13 +36,15 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => { + verify: (s: AssertableState): void => { s.assertFileExists("file-1.md") .assertFileExists("file-3.md") .assertFileExists("file-5.md") .assertFileNotExists("file-2.md") .assertFileNotExists("file-4.md"); - s.ifFileExists("renamed.md", (s) => s.assertContent("renamed.md", "content-2")); + s.ifFileExists("renamed.md", (inner) => + inner.assertContent("renamed.md", "content-2") + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts b/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts index 8144bbb5..3ab451e2 100644 --- a/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = { @@ -19,12 +20,24 @@ export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = { { type: "sync", client: 1 }, { type: "sync", client: 0 }, - { type: "update", client: 2, path: "A.md", content: "updated-by-client-2" }, + { + type: "update", + client: 2, + path: "A.md", + content: "updated-by-client-2" + }, { type: "enable-sync", client: 2 }, { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileNotExists("A.md").assertContains("B.md", "updated-by-client-2") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1) + .assertFileNotExists("A.md") + .assertContains("B.md", "updated-by-client-2"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts b/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts index a4f6d3d3..a230df24 100644 --- a/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts +++ b/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const migrateKeyPreservesExistingTest: TestDefinition = { @@ -25,6 +26,14 @@ export const migrateKeyPreservesExistingTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "updated by client 0") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "A.md", + "updated by client 0" + ); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts b/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts index f590f5b4..c1453390 100644 --- a/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const moveAndConcurrentRemoteUpdateTest: TestDefinition = { @@ -32,6 +33,13 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileNotExists("A.md").assertContains("B.md", "updated by client 1") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(1) + .assertFileNotExists("A.md") + .assertContains("B.md", "updated by client 1"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts b/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts index 59bedbbe..aae5f18c 100644 --- a/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const movePreservesRemoteUpdateTest: TestDefinition = { @@ -6,7 +7,12 @@ export const movePreservesRemoteUpdateTest: TestDefinition = { "After both reconnect, the renamed file should contain client 1's edit.", clients: 2, steps: [ - { type: "create", client: 0, path: "doc.md", content: "line 1\nline 2" }, + { + type: "create", + client: 0, + path: "doc.md", + content: "line 1\nline 2" + }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, @@ -16,7 +22,12 @@ export const movePreservesRemoteUpdateTest: TestDefinition = { { type: "disable-sync", client: 1 }, { type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" }, - { type: "update", client: 1, path: "doc.md", content: "line 1\nclient 1 edit\nline 2" }, + { + type: "update", + client: 1, + path: "doc.md", + content: "line 1\nclient 1 edit\nline 2" + }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, @@ -25,13 +36,15 @@ export const movePreservesRemoteUpdateTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => { + verify: (s: AssertableState): void => { s.assertFileCount(1); - const content = Array.from(s.files.values())[0]; + const [content] = Array.from(s.files.values()); if (!content.includes("client 1 edit")) { - throw new Error(`Expected merged content to include "client 1 edit", got: "${content}"`); + throw new Error( + `Expected merged content to include "client 1 edit", got: "${content}"` + ); } } - }, - ], + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts b/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts index 95fcfe26..29e3fd27 100644 --- a/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const moveRemoteUpdateRevertsRenameTest: TestDefinition = { @@ -13,7 +14,12 @@ export const moveRemoteUpdateRevertsRenameTest: TestDefinition = { { type: "barrier" }, { type: "disable-sync", client: 0 }, - { type: "update", client: 1, path: "doc.md", content: "updated by client 1" }, + { + type: "update", + client: 1, + path: "doc.md", + content: "updated by client 1" + }, { type: "sync", client: 1 }, { type: "enable-sync", client: 0 }, @@ -23,11 +29,13 @@ export const moveRemoteUpdateRevertsRenameTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => { + verify: (s: AssertableState): void => { s.assertFileCount(1); - const content = Array.from(s.files.values())[0]; + const [content] = Array.from(s.files.values()); if (content !== "updated by client 1") { - throw new Error(`Expected "updated by client 1", got: "${content}"`); + throw new Error( + `Expected "updated by client 1", got: "${content}"` + ); } } } diff --git a/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts b/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts index 77814669..dbbec7af 100644 --- a/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts +++ b/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const moveThenDeleteStalePathTest: TestDefinition = { @@ -23,6 +24,13 @@ export const moveThenDeleteStalePathTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(0).assertFileNotExists("A.md").assertFileNotExists("B.md") } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0) + .assertFileNotExists("A.md") + .assertFileNotExists("B.md"); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts index 66efd778..b241433d 100644 --- a/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts +++ b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const multiFileOperationsTest: TestDefinition = { @@ -19,7 +20,12 @@ export const multiFileOperationsTest: TestDefinition = { { type: "delete", client: 0, path: "A.md" }, { type: "sync", client: 0 }, - { type: "update", client: 1, path: "B.md", content: "updated by client 1" }, + { + type: "update", + client: 1, + path: "B.md", + content: "updated by client 1" + }, { type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" }, { type: "enable-sync", client: 1 }, @@ -28,11 +34,13 @@ export const multiFileOperationsTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => { + verify: (s: AssertableState): void => { s.assertContains("B.md", "updated") .assertFileExists("C.md") .assertFileNotExists("A.md"); - s.ifFileExists("D.md", (s) => s.assertContent("D.md", "content-a")); + s.ifFileExists("D.md", (inner) => + inner.assertContent("D.md", "content-a") + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts index 56ecc00d..ff16608b 100644 --- a/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const offlineConcurrentRenamesTest: TestDefinition = { @@ -15,7 +16,9 @@ export const offlineConcurrentRenamesTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertContent("A.md", "shared-content") + verify: (s: AssertableState): void => { + s.assertContent("A.md", "shared-content"); + } }, { type: "disable-sync", client: 0 }, @@ -42,15 +45,15 @@ export const offlineConcurrentRenamesTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => { + verify: (s: AssertableState): void => { s.assertFileNotExists("A.md") .assertFileCount(1) .assertAnyFileContains("shared-content"); - s.ifFileExists("B.md", (s) => - s.assertContent("B.md", "shared-content") + s.ifFileExists("B.md", (inner) => + inner.assertContent("B.md", "shared-content") ); - s.ifFileExists("C.md", (s) => - s.assertContent("C.md", "shared-content") + s.ifFileExists("C.md", (inner) => + inner.assertContent("C.md", "shared-content") ); } } diff --git a/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts b/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts index ca777563..9a4939ef 100644 --- a/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const offlineCreateSamePathMergeableTest: TestDefinition = { @@ -27,15 +28,15 @@ export const offlineCreateSamePathMergeableTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileCount(1) + verify: (s: AssertableState): void => { + s.assertFileCount(1) .assertFileExists("notes.md") .assertContains( "notes.md", "alpha wrote this line", "beta wrote this different line" - ) + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts index ed242b20..1e9ea8f7 100644 --- a/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const offlineDeleteRemoteRenameTest: TestDefinition = { @@ -27,9 +28,10 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => { - s.assertFileNotExists("A.md") - .assertFileNotExists("A_renamed.md"); + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md").assertFileNotExists( + "A_renamed.md" + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts b/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts index d86e3066..73db9efa 100644 --- a/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const offlineDeleteVsRemoteUpdateTest: TestDefinition = { @@ -17,7 +18,9 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertContent("A.md", "original content") + verify: (s: AssertableState): void => { + s.assertContent("A.md", "original content"); + } }, { type: "disable-sync", client: 0 }, @@ -37,7 +40,9 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => s.assertFileCount(0) + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts index fc4383e4..0d6c0be5 100644 --- a/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const offlineEditRemoteRenameTest: TestDefinition = { @@ -13,7 +14,9 @@ export const offlineEditRemoteRenameTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertContent("A.md", "original") + verify: (s: AssertableState): void => { + s.assertContent("A.md", "original"); + } }, { type: "disable-sync", client: 0 }, @@ -38,11 +41,11 @@ export const offlineEditRemoteRenameTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileNotExists("A.md") + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md") .assertFileCount(1) - .assertContains("B.md", "edited by client 0") + .assertContains("B.md", "edited by client 0"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts b/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts index 77d50099..074874a8 100644 --- a/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const offlineEditThenMoveSameContentTest: TestDefinition = { @@ -41,12 +42,12 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileNotExists("A.md") + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md") .assertFileNotExists("B.md") .assertContent("C.md", "content A") - .assertFileCount(1) + .assertFileCount(1); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts b/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts index 68453a0e..06f890d1 100644 --- a/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const offlineMixedOperationsTest: TestDefinition = { @@ -17,11 +18,11 @@ export const offlineMixedOperationsTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertContent("file1.md", "content-1") + verify: (s: AssertableState): void => { + s.assertContent("file1.md", "content-1") .assertContent("file2.md", "content-2") - .assertContent("file3.md", "content-3") + .assertContent("file3.md", "content-3"); + } }, { type: "disable-sync", client: 0 }, @@ -46,13 +47,13 @@ export const offlineMixedOperationsTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileNotExists("file1.md") + verify: (s: AssertableState): void => { + s.assertFileNotExists("file1.md") .assertFileNotExists("file2.md") .assertContent("moved.md", "content-2") .assertContent("file3.md", "updated-content-3") - .assertFileCount(2) + .assertFileCount(2); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts index d1522528..1ded0e6e 100644 --- a/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const offlineMoveThenRemoteDeleteTest: TestDefinition = { @@ -29,11 +30,11 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileNotExists("A.md") + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md") .assertFileNotExists("B.md") - .assertFileCount(0) + .assertFileCount(0); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts b/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts index e242223a..08aed64d 100644 --- a/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const offlineMultipleEditsTest: TestDefinition = { @@ -14,7 +15,9 @@ export const offlineMultipleEditsTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertContent("doc.md", "original") + verify: (s: AssertableState): void => { + s.assertContent("doc.md", "original"); + } }, { type: "disable-sync", client: 0 }, @@ -31,8 +34,9 @@ export const offlineMultipleEditsTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileCount(1).assertContent("doc.md", "edit-5-final") + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "edit-5-final"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts index c446d459..0cc02c88 100644 --- a/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const offlineRenameAndEditTest: TestDefinition = { @@ -14,12 +15,19 @@ export const offlineRenameAndEditTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertContent("A.md", "original") + verify: (s: AssertableState): void => { + s.assertContent("A.md", "original"); + } }, { type: "disable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - { type: "update", client: 0, path: "B.md", content: "edited after rename" }, + { + type: "update", + client: 0, + path: "B.md", + content: "edited after rename" + }, { type: "enable-sync", client: 0 }, { type: "sync" }, @@ -27,11 +35,11 @@ export const offlineRenameAndEditTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileNotExists("A.md") + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md") .assertFileCount(1) - .assertContent("B.md", "edited after rename") + .assertContent("B.md", "edited after rename"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts index 24f4ff2a..b20061f6 100644 --- a/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { @@ -14,7 +15,9 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertContent("X.md", "original") + verify: (s: AssertableState): void => { + s.assertContent("X.md", "original"); + } }, { type: "disable-sync", client: 0 }, @@ -39,10 +42,12 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileCount(1) - .assertContains("Y.md", "updated-by-client-1") + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "Y.md", + "updated-by-client-1" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts b/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts index 47a88328..3019f2ae 100644 --- a/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { @@ -26,10 +27,12 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s - .assertContent("A.md", "A original") - .assertContent("B.md", "B original") + verify: (s: AssertableState): void => { + s.assertContent("A.md", "A original").assertContent( + "B.md", + "B original" + ); + } }, { type: "disable-sync", client: 0 }, @@ -63,10 +66,12 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertContent("A.md", "A updated by client 0") - .assertFileNotExists("B.md") + verify: (s: AssertableState): void => { + s.assertContent( + "A.md", + "A updated by client 0" + ).assertFileNotExists("B.md"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts b/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts index 1639ed90..b951b0be 100644 --- a/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts +++ b/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const onlineBothCreateSamePathDeconflictTest: TestDefinition = { @@ -23,7 +24,7 @@ export const onlineBothCreateSamePathDeconflictTest: TestDefinition = { { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileCount(1) .assertContains("A.md", "updated-by-0", "from-client-1 "); diff --git a/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts b/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts index 3449e676..f86b3347 100644 --- a/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts +++ b/frontend/deterministic-tests/src/tests/online-create-rename-concurrent-create-orphan.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = { @@ -12,8 +13,18 @@ export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = { { type: "disable-sync", client: 0 }, - { type: "create", client: 0, path: "data.bin", content: "BINARY:offline-content" }, - { type: "rename", client: 0, oldPath: "data.bin", newPath: "moved.bin" }, + { + type: "create", + client: 0, + path: "data.bin", + content: "BINARY:offline-content" + }, + { + type: "rename", + client: 0, + oldPath: "data.bin", + newPath: "moved.bin" + }, { type: "enable-sync", client: 0 }, { type: "delete", client: 0, path: "moved.bin" }, @@ -22,7 +33,7 @@ export const onlineCreateRenameConcurrentCreateOrphanTest: TestDefinition = { { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state.assertFileCount(0); } } diff --git a/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts b/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts index f59a92e3..68a64e9f 100644 --- a/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = { @@ -11,16 +12,33 @@ export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = { { 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: "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) + type: "assert-consistent", + verify: (state: AssertableState): void => { + state + .assertFileCount(2) .assertContains("data.bin", "content-v2") .assertContains("data (1).bin", "other-content"); } diff --git a/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts b/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts index b575aa58..de5d6c89 100644 --- a/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts +++ b/frontend/deterministic-tests/src/tests/online-delete-recreate-rapid-cycle.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const onlineDeleteRecreateRapidCycleTest: TestDefinition = { @@ -28,7 +29,9 @@ export const onlineDeleteRecreateRapidCycleTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => s.assertContent("A.md", "round 3"), - }, - ], + verify: (s: AssertableState): void => { + s.assertContent("A.md", "round 3"); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts b/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts index 16ed7236..41a9d871 100644 --- a/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts +++ b/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const onlineEditVsDeleteConvergenceTest: TestDefinition = { @@ -11,17 +12,22 @@ export const onlineEditVsDeleteConvergenceTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "update", client: 0, path: "A.md", content: "edited by client 0" }, + { + type: "update", + client: 0, + path: "A.md", + content: "edited by client 0" + }, { type: "delete", client: 1, path: "A.md" }, { type: "barrier" }, { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state.ifFileExists("A.md", (s) => s.assertContainsAny("A.md", "edited by client 0") ); } - }, - ], + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts b/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts index eeb705de..14b013d6 100644 --- a/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts +++ b/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const overlappingEditsSameSectionTest: TestDefinition = { @@ -41,9 +42,15 @@ export const overlappingEditsSameSectionTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileCount(1) - .assertContains("doc.md", "# Title", "alpha addition", "beta addition", "footer"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "doc.md", + "# Title", + "alpha addition", + "beta addition", + "footer" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts b/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts index ecf58d05..6d89acf4 100644 --- a/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts +++ b/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const queueResetLosesCoalescedLocalEditTest: TestDefinition = { @@ -23,8 +24,13 @@ export const queueResetLosesCoalescedLocalEditTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileCount(1).assertContains("doc.md", "alpha", "charlie"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "doc.md", + "alpha", + "charlie" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts index 45f90144..db9ed848 100644 --- a/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts +++ b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { @@ -41,7 +42,12 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => s.assertFileCount(1).assertContent("cycle.md", "final creation"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent( + "cycle.md", + "final creation" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts b/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts index 042942b3..48c062e0 100644 --- a/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts +++ b/frontend/deterministic-tests/src/tests/rapid-edit-delete-online-convergence.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const rapidEditDeleteOnlineConvergenceTest: TestDefinition = { @@ -28,17 +29,20 @@ export const rapidEditDeleteOnlineConvergenceTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => { + verify: (s: AssertableState): void => { for (const [path, content] of s.files) { for (const clientFiles of s.clientFiles) { - if (clientFiles.has(path) && clientFiles.get(path) !== content) { + if ( + clientFiles.has(path) && + clientFiles.get(path) !== content + ) { throw new Error( `Content mismatch for ${path}: "${clientFiles.get(path)}" vs "${content}"` ); } } } - }, - }, - ], + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts b/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts index bf0ed488..1a155814 100644 --- a/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts +++ b/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const rapidUpdatesAfterMergeTest: TestDefinition = { @@ -42,7 +43,9 @@ export const rapidUpdatesAfterMergeTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => s.assertFileCount(1).assertContains("doc.md", "update 3"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains("doc.md", "update 3"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts b/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts index 128cd90e..c8e70243 100644 --- a/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts +++ b/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const recentlyDeletedClearedOnReconnectTest: TestDefinition = { @@ -19,7 +20,12 @@ export const recentlyDeletedClearedOnReconnectTest: TestDefinition = { { 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: "enable-sync", client: 1 }, { type: "sync", client: 1 }, @@ -28,8 +34,12 @@ export const recentlyDeletedClearedOnReconnectTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileCount(1).assertContent("doc.md", "new content from client 1"), - }, - ], + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent( + "doc.md", + "new content from client 1" + ); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts index 27787e4f..97661f4f 100644 --- a/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameChainThenDeleteTest: TestDefinition = { @@ -13,7 +14,9 @@ export const renameChainThenDeleteTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertContent("X.md", "chain-content"), + verify: (s: AssertableState): void => { + s.assertContent("X.md", "chain-content"); + } }, { type: "disable-sync", client: 1 }, @@ -39,6 +42,11 @@ export const renameChainThenDeleteTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(0) } + { + type: "assert-consistent", + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-chain.test.ts b/frontend/deterministic-tests/src/tests/rename-chain.test.ts index 8cc3bde3..15365fc1 100644 --- a/frontend/deterministic-tests/src/tests/rename-chain.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-chain.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameChainTest: TestDefinition = { @@ -9,7 +10,12 @@ export const renameChainTest: TestDefinition = { steps: [ { type: "enable-sync", client: 1 }, - { type: "create", client: 0, path: "A.md", content: "important content" }, + { + type: "create", + client: 0, + path: "A.md", + content: "important content" + }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" }, @@ -19,10 +25,11 @@ export const renameChainTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => + verify: (s: AssertableState): void => { s.assertFileNotExists("A.md") .assertFileNotExists("B.md") - .assertContent("C.md", "important content"), + .assertContent("C.md", "important content"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-circular.test.ts b/frontend/deterministic-tests/src/tests/rename-circular.test.ts index 5c85ca71..508182cd 100644 --- a/frontend/deterministic-tests/src/tests/rename-circular.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-circular.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameCircularTest: TestDefinition = { @@ -13,10 +14,11 @@ export const renameCircularTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => + verify: (s: AssertableState): void => { s.assertContent("A.md", "content-a") .assertContent("B.md", "content-b") - .assertContent("C.md", "content-c"), + .assertContent("C.md", "content-c"); + } }, { type: "disable-sync", client: 0 }, @@ -31,12 +33,13 @@ export const renameCircularTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => + verify: (s: AssertableState): void => { s.assertFileNotExists("temp-a.md") .assertFileCount(3) .assertContent("A.md", "content-c") .assertContent("B.md", "content-a") - .assertContent("C.md", "content-b"), + .assertContent("C.md", "content-b"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts index c29b1dc5..635e6e91 100644 --- a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameCreateConflictTest: TestDefinition = { @@ -12,7 +13,9 @@ export const renameCreateConflictTest: TestDefinition = { { type: "sync", client: 1 }, { type: "assert-consistent", - verify: (s) => s.assertContent("A.md", "hi"), + verify: (s: AssertableState): void => { + s.assertContent("A.md", "hi"); + } }, { type: "disable-sync", client: 0 }, { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, @@ -23,8 +26,9 @@ export const renameCreateConflictTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s.assertFileNotExists("A.md").assertContent("B.md", "hi"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md").assertContent("B.md", "hi"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts b/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts index d38a0392..639c51e3 100644 --- a/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renamePendingCreateBeforeResponseTest: TestDefinition = { @@ -34,8 +35,12 @@ export const renamePendingCreateBeforeResponseTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileCount(1).assertContent("renamed.md", "original-content"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent( + "renamed.md", + "original-content" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts index bdf043f4..19a1240f 100644 --- a/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameRoundtripTest: TestDefinition = { @@ -12,7 +13,9 @@ export const renameRoundtripTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertContent("A.md", "original"), + verify: (s: AssertableState): void => { + s.assertContent("A.md", "original"); + } }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, @@ -21,8 +24,9 @@ export const renameRoundtripTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileNotExists("A.md").assertContent("B.md", "original"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md").assertContent("B.md", "original"); + } }, { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, @@ -31,8 +35,9 @@ export const renameRoundtripTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileNotExists("B.md").assertContent("A.md", "original"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("B.md").assertContent("A.md", "original"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-swap.test.ts b/frontend/deterministic-tests/src/tests/rename-swap.test.ts index 18489f33..d531c725 100644 --- a/frontend/deterministic-tests/src/tests/rename-swap.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-swap.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameSwapTest: TestDefinition = { @@ -15,8 +16,12 @@ export const renameSwapTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s.assertContent("A.md", "content-a").assertContent("B.md", "content-b"), + verify: (s: AssertableState): void => { + s.assertContent("A.md", "content-a").assertContent( + "B.md", + "content-b" + ); + } }, { type: "disable-sync", client: 0 }, @@ -29,12 +34,12 @@ export const renameSwapTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileNotExists("temp.md") + verify: (s: AssertableState): void => { + s.assertFileNotExists("temp.md") .assertFileCount(2) .assertContent("A.md", "content-b") - .assertContent("B.md", "content-a"), + .assertContent("B.md", "content-a"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts b/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts index b1d09c7f..ddb59e11 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameToExistingPathTest: TestDefinition = { @@ -19,8 +20,9 @@ export const renameToExistingPathTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileNotExists("A.md").assertContent("B.md", "alpha"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md").assertContent("B.md", "alpha"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts index b5745e3b..34a3867c 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = { @@ -32,10 +33,12 @@ export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileNotExists("B.md") - .assertContains("A.md", "content B"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("B.md").assertContains( + "A.md", + "content B" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts b/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts index a17f52d4..1d65f9ee 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameToPendingPathFallbackTest: TestDefinition = { @@ -5,7 +6,12 @@ export const renameToPendingPathFallbackTest: TestDefinition = { "Client 0 creates B.md and syncs. Goes offline, creates A.md, then renames B.md to A.md (overwriting the unsynced A). After reconnecting, B.md should be gone and A.md should have B's content.", clients: 2, steps: [ - { type: "create", client: 0, path: "B.md", content: "tracked B content" }, + { + type: "create", + client: 0, + path: "B.md", + content: "tracked B content" + }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, @@ -13,7 +19,12 @@ export const renameToPendingPathFallbackTest: TestDefinition = { { type: "disable-sync", client: 0 }, - { type: "create", client: 0, path: "A.md", content: "pending A content" }, + { + type: "create", + client: 0, + path: "A.md", + content: "pending A content" + }, { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, @@ -23,8 +34,12 @@ export const renameToPendingPathFallbackTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileNotExists("B.md").assertContains("A.md", "tracked B content"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("B.md").assertContains( + "A.md", + "tracked B content" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts b/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts index 754c0c18..a7a8a9a5 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameToRecentlyDeletedPathTest: TestDefinition = { @@ -30,11 +31,11 @@ export const renameToRecentlyDeletedPathTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileCount(1) + verify: (s: AssertableState): void => { + s.assertFileCount(1) .assertFileNotExists("A.md") - .assertContent("B.md", "content-a"), + .assertContent("B.md", "content-a"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts index 099009fb..27cae589 100644 --- a/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const renameUpdateConflictTest: TestDefinition = { @@ -12,7 +13,9 @@ export const renameUpdateConflictTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertContent("A.md", "original"), + verify: (s: AssertableState): void => { + s.assertContent("A.md", "original"); + } }, { type: "disable-sync", client: 1 }, @@ -20,7 +23,12 @@ export const renameUpdateConflictTest: TestDefinition = { { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "sync", client: 0 }, - { type: "update", client: 1, path: "A.md", content: "updated by client 1" }, + { + type: "update", + client: 1, + path: "A.md", + content: "updated by client 1" + }, { type: "enable-sync", client: 1 }, { type: "sync", client: 1 }, @@ -28,8 +36,9 @@ export const renameUpdateConflictTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileNotExists("A.md").assertContains("B.md", "updated"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("A.md").assertContains("B.md", "updated"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts b/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts index e7b001e2..5b14256a 100644 --- a/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts +++ b/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { @@ -26,7 +27,9 @@ export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => s.assertFileNotExists("ghost.md"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("ghost.md"); + } }, { type: "disable-sync", client: 1 }, @@ -36,7 +39,9 @@ export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => s.assertFileCount(0), + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts b/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts index 968166a9..0169fbba 100644 --- a/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts +++ b/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const sequentialCreateDuplicateContentTest: TestDefinition = { @@ -5,7 +6,12 @@ export const sequentialCreateDuplicateContentTest: TestDefinition = { "Client 0 creates A.md, syncs, then creates B.md with identical content. Both files must remain as separate documents on both clients.", clients: 2, steps: [ - { type: "create", client: 0, path: "A.md", content: "identical content here" }, + { + type: "create", + client: 0, + path: "A.md", + content: "identical content here" + }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, @@ -13,20 +19,27 @@ export const sequentialCreateDuplicateContentTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => s.assertContent("A.md", "identical content here"), + verify: (s: AssertableState): void => { + s.assertContent("A.md", "identical content here"); + } }, - { type: "create", client: 0, path: "B.md", content: "identical content here" }, + { + type: "create", + client: 0, + path: "B.md", + content: "identical content here" + }, { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s - .assertFileCount(2) + verify: (s: AssertableState): void => { + s.assertFileCount(2) .assertContent("A.md", "identical content here") - .assertContent("B.md", "identical content here"), + .assertContent("B.md", "identical content here"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts b/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts index fea4adad..359f1a36 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const serverPauseBothClientsCreateTest: TestDefinition = { @@ -32,10 +33,12 @@ export const serverPauseBothClientsCreateTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertContains("alpha.md", "from client 0") - .assertContains("beta.md", "from client 1"), + verify: (s: AssertableState): void => { + s.assertContains("alpha.md", "from client 0").assertContains( + "beta.md", + "from client 1" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts b/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts index 394a531a..e09c8e6c 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const serverPauseBothEditSameFileTest: TestDefinition = { @@ -39,10 +40,13 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileCount(1) - .assertContains("shared.md", "edited by client 0", "edited by client 1"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "shared.md", + "edited by client 0", + "edited by client 1" + ); + } }, { @@ -56,8 +60,12 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileCount(1).assertContains("shared.md", "post-merge edit from client 0"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContains( + "shared.md", + "post-merge edit from client 0" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts b/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts index 920259e1..5ac97f0d 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-delete-recreate.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const serverPauseDeleteRecreateTest: TestDefinition = { @@ -15,18 +16,23 @@ export const serverPauseDeleteRecreateTest: TestDefinition = { { type: "pause-server" }, - { type: "create", client: 0, path: "A.md", content: "recreated during contention" }, + { + type: "create", + client: 0, + path: "A.md", + content: "recreated during contention" + }, { type: "resume-server" }, { type: "barrier" }, { type: "assert-consistent", - verify: (state) => { + verify: (state: AssertableState): void => { state .assertFileCount(1) .assertContent("A.md", "recreated during contention"); } - }, - ], + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts b/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts index c2d6772e..2f378921 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const serverPauseRenameEditResumeTest: TestDefinition = { @@ -19,7 +20,9 @@ export const serverPauseRenameEditResumeTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertContent("A.md", "original content"), + verify: (s: AssertableState): void => { + s.assertContent("A.md", "original content"); + } }, { type: "pause-server" }, @@ -39,11 +42,11 @@ export const serverPauseRenameEditResumeTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileCount(1) + verify: (s: AssertableState): void => { + s.assertFileCount(1) .assertFileNotExists("A.md") - .assertContent("B.md", "edited after rename during pause"), + .assertContent("B.md", "edited after rename during pause"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts b/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts index 3523cf79..e10e37d9 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const serverPauseUpdateAndCreateTest: TestDefinition = { @@ -17,7 +18,9 @@ export const serverPauseUpdateAndCreateTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertContent("shared.md", "initial content"), + verify: (s: AssertableState): void => { + s.assertContent("shared.md", "initial content"); + } }, { type: "pause-server" }, @@ -42,10 +45,12 @@ export const serverPauseUpdateAndCreateTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertContent("shared.md", "updated during pause") - .assertContent("new-file.md", "created by client 1"), + verify: (s: AssertableState): void => { + s.assertContent( + "shared.md", + "updated during pause" + ).assertContent("new-file.md", "created by client 1"); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts index 2e74b3a5..c7f71165 100644 --- a/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const simultaneousCreateDeleteSamePathTest: TestDefinition = { @@ -18,7 +19,12 @@ export const simultaneousCreateDeleteSamePathTest: TestDefinition = { { type: "delete", client: 0, path: "A.md" }, { type: "sync", client: 0 }, - { type: "update", client: 1, path: "A.md", content: "modified by 1 while offline" }, + { + type: "update", + client: 1, + path: "A.md", + content: "modified by 1 while offline" + }, { type: "enable-sync", client: 1 }, { type: "sync", client: 1 }, @@ -26,14 +32,16 @@ export const simultaneousCreateDeleteSamePathTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => { - s.ifFileExists("A.md", (s) => - s.assertFileCount(1).assertContent("A.md", "modified by 1 while offline") + verify: (s: AssertableState): void => { + s.ifFileExists("A.md", (inner) => + inner + .assertFileCount(1) + .assertContent("A.md", "modified by 1 while offline") ); if (!s.files.has("A.md")) { s.assertFileCount(0); } - }, + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts b/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts index 174bcdc4..80478adc 100644 --- a/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const threeClientRenameCreateDeleteTest: TestDefinition = { @@ -44,10 +45,11 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s - .assertFileNotExists("X.md") - .assertAnyFileContains("new from C"), + verify: (s: AssertableState): void => { + s.assertFileNotExists("X.md").assertAnyFileContains( + "new from C" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts b/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts index 43536bed..54c1beaf 100644 --- a/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts +++ b/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const updateDuringCreateProcessingTest: TestDefinition = { @@ -32,8 +33,12 @@ export const updateDuringCreateProcessingTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileCount(1).assertContent("file.md", "updated during create"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent( + "file.md", + "updated during create" + ); + } } ] }; diff --git a/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts index 09ec9427..70a2fc8c 100644 --- a/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = { @@ -14,7 +15,12 @@ export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = { { type: "disable-sync", client: 1 }, { type: "delete", client: 0, path: "doc.md" }, - { 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: 1 }, @@ -22,8 +28,9 @@ export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileCount(0) - }, - ], + verify: (s: AssertableState): void => { + s.assertFileCount(0); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts b/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts index 202bd437..0212a19f 100644 --- a/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts +++ b/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const watermarkAdvancesOnSkipTest: TestDefinition = { @@ -29,7 +30,9 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => s.assertFileCount(1).assertFileExists("doc.md"), - }, - ], + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertFileExists("doc.md"); + } + } + ] }; diff --git a/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts b/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts index 0f5ade3d..0ee606f0 100644 --- a/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts +++ b/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts @@ -1,3 +1,4 @@ +import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = { @@ -20,8 +21,9 @@ export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => - s.assertFileCount(1).assertContent("doc.md", "update 2"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "update 2"); + } }, { type: "disable-sync", client: 1 }, @@ -31,8 +33,9 @@ export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = { { type: "assert-consistent", - verify: (s) => - s.assertFileCount(1).assertContent("doc.md", "update 2"), + verify: (s: AssertableState): void => { + s.assertFileCount(1).assertContent("doc.md", "update 2"); + } } ] }; diff --git a/frontend/deterministic-tests/src/utils/assertable-state.ts b/frontend/deterministic-tests/src/utils/assertable-state.ts index 05414342..196333c0 100644 --- a/frontend/deterministic-tests/src/utils/assertable-state.ts +++ b/frontend/deterministic-tests/src/utils/assertable-state.ts @@ -1,15 +1,15 @@ import type { ClientState } from "../test-definition"; export class AssertableState { - readonly files: Map; - readonly clientFiles: Map[]; + public readonly files: Map; + public readonly clientFiles: Map[]; - constructor(state: ClientState) { + public constructor(state: ClientState) { this.files = state.files; this.clientFiles = state.clientFiles; } - assertFileCount(expected: number): this { + public assertFileCount(expected: number): this { if (this.files.size !== expected) { const keys = Array.from(this.files.keys()).join(", "); throw new Error( @@ -19,17 +19,15 @@ export class AssertableState { return this; } - assertFileExists(path: string): this { + public assertFileExists(path: string): this { if (!this.files.has(path)) { const keys = Array.from(this.files.keys()).join(", "); - throw new Error( - `Expected "${path}" to exist. Files: [${keys}]` - ); + throw new Error(`Expected "${path}" to exist. Files: [${keys}]`); } return this; } - assertFileNotExists(path: string): this { + public assertFileNotExists(path: string): this { if (this.files.has(path)) { const keys = Array.from(this.files.keys()).join(", "); throw new Error( @@ -39,7 +37,7 @@ export class AssertableState { return this; } - assertContent(path: string, expected: string): this { + public assertContent(path: string, expected: string): this { this.assertFileExists(path); const actual = this.files.get(path) ?? ""; if (actual !== expected) { @@ -50,7 +48,7 @@ export class AssertableState { return this; } - assertContains(path: string, ...substrings: string[]): this { + public assertContains(path: string, ...substrings: string[]): this { this.assertFileExists(path); const content = this.files.get(path) ?? ""; const missing = substrings.filter((s) => !content.includes(s)); @@ -62,7 +60,7 @@ export class AssertableState { return this; } - assertContainsAny(path: string, ...substrings: string[]): this { + public assertContainsAny(path: string, ...substrings: string[]): this { this.assertFileExists(path); const content = this.files.get(path) ?? ""; const found = substrings.some((s) => content.includes(s)); @@ -74,7 +72,7 @@ export class AssertableState { return this; } - assertAnyFileContains(...substrings: string[]): this { + public assertAnyFileContains(...substrings: string[]): this { const allContent = Array.from(this.files.values()).join("\n"); const missing = substrings.filter((s) => !allContent.includes(s)); if (missing.length > 0) { @@ -88,7 +86,7 @@ export class AssertableState { return this; } - assertSubstringCount( + public assertSubstringCount( path: string, substring: string, expected: number @@ -104,7 +102,7 @@ export class AssertableState { return this; } - assertContentInAtMostOneFile(substring: string): this { + public assertContentInAtMostOneFile(substring: string): this { const matches = Array.from(this.files.entries()).filter(([, content]) => content.includes(substring) ); @@ -119,14 +117,14 @@ export class AssertableState { return this; } - ifFileExists(path: string, fn: (state: this) => void): this { + public ifFileExists(path: string, fn: (state: this) => void): this { if (this.files.has(path)) { fn(this); } return this; } - getContent(path: string): string { + public getContent(path: string): string { return this.files.get(path) ?? ""; } } diff --git a/frontend/history-ui/src/lib/api.ts b/frontend/history-ui/src/lib/api.ts index a69a575e..7c365b57 100644 --- a/frontend/history-ui/src/lib/api.ts +++ b/frontend/history-ui/src/lib/api.ts @@ -28,9 +28,7 @@ async function fetchJsonWithToken( return response.json() as Promise; } -export async function listVaults( - token: string -): Promise { +export async function listVaults(token: string): Promise { return fetchJsonWithToken("/vaults", token); } @@ -44,10 +42,7 @@ export class ApiClient { return `/vaults/${encodeURIComponent(this.vaultId)}`; } - private async fetchJson( - path: string, - init?: RequestInit - ): Promise { + private async fetchJson(path: string, init?: RequestInit): Promise { return fetchJsonWithToken(path, this.token, init); } @@ -104,9 +99,7 @@ export class ApiClient { if (beforeUpdateId !== undefined) params.set("before_update_id", String(beforeUpdateId)); const qs = params.toString(); - return this.fetchJson( - `${this.baseUrl}/history${qs ? `?${qs}` : ""}` - ); + return this.fetchJson(`${this.baseUrl}/history${qs ? `?${qs}` : ""}`); } /** diff --git a/frontend/history-ui/src/lib/stores.svelte.ts b/frontend/history-ui/src/lib/stores.svelte.ts index fcba5340..458ec5e7 100644 --- a/frontend/history-ui/src/lib/stores.svelte.ts +++ b/frontend/history-ui/src/lib/stores.svelte.ts @@ -16,11 +16,7 @@ class AuthStore { isAuthenticated = $state(false); api = $state(null); - authenticate( - token: string, - userName: string, - vaults: VaultInfo[] - ) { + authenticate(token: string, userName: string, vaults: VaultInfo[]) { this.token = token; this.userName = userName; this.availableVaults = vaults; @@ -56,8 +52,7 @@ class AuthStore { tryRestore(): { token: string; vaultId?: string } | null { const token = sessionStorage.getItem("vaultlink_token"); if (!token) return null; - const vaultId = - sessionStorage.getItem("vaultlink_vault") ?? undefined; + const vaultId = sessionStorage.getItem("vaultlink_vault") ?? undefined; return { token, vaultId }; } } @@ -115,13 +110,8 @@ export function inferAction( ): ActionType { if (version.isDeleted) return "deleted"; if (!previousVersion) return "created"; - if ( - previousVersion.isDeleted && - !version.isDeleted - ) - return "restored"; - if (previousVersion.relativePath !== version.relativePath) - return "renamed"; + if (previousVersion.isDeleted && !version.isDeleted) return "restored"; + if (previousVersion.relativePath !== version.relativePath) return "renamed"; return "updated"; } @@ -150,8 +140,7 @@ export function enrichVersions( return { ...v, action, - previousPath: - action === "renamed" ? prev?.relativePath : undefined + previousPath: action === "renamed" ? prev?.relativePath : undefined }; }); } diff --git a/frontend/history-ui/src/lib/types/ClientCursors.ts b/frontend/history-ui/src/lib/types/ClientCursors.ts index bb629100..14298431 100644 --- a/frontend/history-ui/src/lib/types/ClientCursors.ts +++ b/frontend/history-ui/src/lib/types/ClientCursors.ts @@ -1,4 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DocumentWithCursors } from "./DocumentWithCursors"; -export type ClientCursors = { userName: string, deviceId: string, documentsWithCursors: Array, }; +export type ClientCursors = { + userName: string; + deviceId: string; + documentsWithCursors: Array; +}; diff --git a/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts b/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts index 29d3f55e..389d8e88 100644 --- a/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts +++ b/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts @@ -1,3 +1,7 @@ // 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, last_seen_vault_update_id: number, content: Array, }; +export type CreateDocumentVersion = { + relative_path: string; + last_seen_vault_update_id: number; + content: Array; +}; diff --git a/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts b/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts index 60b48e5e..5846843e 100644 --- a/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts +++ b/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts @@ -1,4 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DocumentWithCursors } from "./DocumentWithCursors"; -export type CursorPositionFromClient = { documentsWithCursors: Array, }; +export type CursorPositionFromClient = { + documentsWithCursors: Array; +}; diff --git a/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts b/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts index c8444892..3a72c706 100644 --- a/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts +++ b/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts @@ -1,4 +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, }; +export type CursorPositionFromServer = { clients: Array }; diff --git a/frontend/history-ui/src/lib/types/CursorSpan.ts b/frontend/history-ui/src/lib/types/CursorSpan.ts index d0bce6ea..916019ce 100644 --- a/frontend/history-ui/src/lib/types/CursorSpan.ts +++ b/frontend/history-ui/src/lib/types/CursorSpan.ts @@ -1,3 +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, }; +export type CursorSpan = { start: number; end: number }; diff --git a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts b/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts index 51e0b37c..dd7eadda 100644 --- a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts +++ b/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts @@ -5,4 +5,6 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a create/update document request. */ -export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion; +export type DocumentUpdateResponse = + | ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent) + | ({ type: "MergingUpdate" } & DocumentVersion); diff --git a/frontend/history-ui/src/lib/types/DocumentVersion.ts b/frontend/history-ui/src/lib/types/DocumentVersion.ts index 37bd32ca..50a6c591 100644 --- a/frontend/history-ui/src/lib/types/DocumentVersion.ts +++ b/frontend/history-ui/src/lib/types/DocumentVersion.ts @@ -1,3 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DocumentVersion = { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, }; +export type DocumentVersion = { + vaultUpdateId: number; + documentId: string; + relativePath: string; + updatedDate: string; + contentBase64: string; + isDeleted: boolean; + userId: string; + deviceId: string; +}; diff --git a/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts b/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts index 03be2f63..dad1f135 100644 --- a/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts +++ b/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts @@ -1,3 +1,12 @@ // 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, }; +export type DocumentVersionWithoutContent = { + vaultUpdateId: number; + documentId: string; + relativePath: string; + updatedDate: string; + isDeleted: boolean; + userId: string; + deviceId: string; + contentSize: number; +}; diff --git a/frontend/history-ui/src/lib/types/DocumentWithCursors.ts b/frontend/history-ui/src/lib/types/DocumentWithCursors.ts index 3504ce33..ca6a2155 100644 --- a/frontend/history-ui/src/lib/types/DocumentWithCursors.ts +++ b/frontend/history-ui/src/lib/types/DocumentWithCursors.ts @@ -1,4 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CursorSpan } from "./CursorSpan"; -export type DocumentWithCursors = { vaultUpdateId: number | null, documentId: string, relativePath: string, cursors: Array, }; +export type DocumentWithCursors = { + vaultUpdateId: number | null; + documentId: string; + relativePath: string; + cursors: Array; +}; diff --git a/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts b/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts index ce572684..141c2565 100644 --- a/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts +++ b/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts @@ -4,8 +4,10 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a fetch latest documents request. */ -export type FetchLatestDocumentsResponse = { latestDocuments: Array, -/** - * The update ID of the latest document in the response. - */ -lastUpdateId: bigint, }; +export type FetchLatestDocumentsResponse = { + latestDocuments: Array; + /** + * The update ID of the latest document in the response. + */ + lastUpdateId: bigint; +}; diff --git a/frontend/history-ui/src/lib/types/ListVaultsResponse.ts b/frontend/history-ui/src/lib/types/ListVaultsResponse.ts index 92b2b3e0..604ad958 100644 --- a/frontend/history-ui/src/lib/types/ListVaultsResponse.ts +++ b/frontend/history-ui/src/lib/types/ListVaultsResponse.ts @@ -4,4 +4,8 @@ import type { VaultInfo } from "./VaultInfo"; /** * Response to listing vaults accessible to the authenticated user. */ -export type ListVaultsResponse = { vaults: Array, hasMore: boolean, userName: string, }; +export type ListVaultsResponse = { + vaults: Array; + hasMore: boolean; + userName: string; +}; diff --git a/frontend/history-ui/src/lib/types/PingResponse.ts b/frontend/history-ui/src/lib/types/PingResponse.ts index c38845d2..7e5ac4f8 100644 --- a/frontend/history-ui/src/lib/types/PingResponse.ts +++ b/frontend/history-ui/src/lib/types/PingResponse.ts @@ -3,22 +3,23 @@ /** * 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, -/** - * API version ensuring backwards & forwards compatibility between the client - * and server. - */ -supportedApiVersion: number, }; +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; + /** + * API version ensuring backwards & forwards compatibility between the client + * and server. + */ + supportedApiVersion: number; +}; diff --git a/frontend/history-ui/src/lib/types/SerializedError.ts b/frontend/history-ui/src/lib/types/SerializedError.ts index 5e3fa9b9..354305f6 100644 --- a/frontend/history-ui/src/lib/types/SerializedError.ts +++ b/frontend/history-ui/src/lib/types/SerializedError.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type SerializedError = { errorType: string, message: string, causes: Array, }; +export type SerializedError = { + errorType: string; + message: string; + causes: Array; +}; diff --git a/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts b/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts index 458fc2bb..ce0272e3 100644 --- a/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts +++ b/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type UpdateTextDocumentVersion = { parentVersionId: number, relativePath: string, content: Array, }; +export type UpdateTextDocumentVersion = { + parentVersionId: number; + relativePath: string; + content: Array; +}; diff --git a/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts b/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts index ae91b480..e69366f0 100644 --- a/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts +++ b/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts @@ -4,4 +4,7 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a vault history request (paginated). */ -export type VaultHistoryResponse = { versions: Array, hasMore: boolean, }; +export type VaultHistoryResponse = { + versions: Array; + hasMore: boolean; +}; diff --git a/frontend/history-ui/src/lib/types/VaultInfo.ts b/frontend/history-ui/src/lib/types/VaultInfo.ts index 32373346..3f630ae9 100644 --- a/frontend/history-ui/src/lib/types/VaultInfo.ts +++ b/frontend/history-ui/src/lib/types/VaultInfo.ts @@ -3,4 +3,8 @@ /** * Summary of a single vault returned by the list-vaults endpoint. */ -export type VaultInfo = { name: string, documentCount: number, createdAt: string | null, }; +export type VaultInfo = { + name: string; + documentCount: number; + createdAt: string | null; +}; diff --git a/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts b/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts index 5765a0d0..9608f3af 100644 --- a/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts +++ b/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts @@ -2,4 +2,6 @@ import type { CursorPositionFromClient } from "./CursorPositionFromClient"; import type { WebSocketHandshake } from "./WebSocketHandshake"; -export type WebSocketClientMessage = { "type": "handshake" } & WebSocketHandshake | { "type": "cursorPositions" } & CursorPositionFromClient; +export type WebSocketClientMessage = + | ({ type: "handshake" } & WebSocketHandshake) + | ({ type: "cursorPositions" } & CursorPositionFromClient); diff --git a/frontend/history-ui/src/lib/types/WebSocketHandshake.ts b/frontend/history-ui/src/lib/types/WebSocketHandshake.ts index 85c2cf0d..8e51a121 100644 --- a/frontend/history-ui/src/lib/types/WebSocketHandshake.ts +++ b/frontend/history-ui/src/lib/types/WebSocketHandshake.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type WebSocketHandshake = { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, }; +export type WebSocketHandshake = { + token: string; + deviceId: string; + lastSeenVaultUpdateId: number | null; +}; diff --git a/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts b/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts index 45e37358..fd250b7b 100644 --- a/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts +++ b/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts @@ -2,4 +2,6 @@ import type { CursorPositionFromServer } from "./CursorPositionFromServer"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; -export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer; +export type WebSocketServerMessage = + | ({ type: "vaultUpdate" } & WebSocketVaultUpdate) + | ({ type: "cursorPositions" } & CursorPositionFromServer); diff --git a/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts b/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts index fc10827f..94d70c0a 100644 --- a/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts +++ b/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; -export type WebSocketVaultUpdate = { document: DocumentVersionWithoutContent, }; +export type WebSocketVaultUpdate = { document: DocumentVersionWithoutContent }; diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts index 5124b72f..3652a4c7 100644 --- a/frontend/local-client-cli/src/args.ts +++ b/frontend/local-client-cli/src/args.ts @@ -37,22 +37,19 @@ export function parseArgs(argv: string[]): CliArgs { ).env("VAULTLINK_LOCAL_PATH") ) .addOption( - new Option( - "-r, --remote-uri ", - "Remote server URI" - ).env("VAULTLINK_REMOTE_URI") + new Option("-r, --remote-uri ", "Remote server URI").env( + "VAULTLINK_REMOTE_URI" + ) ) .addOption( - new Option( - "-t, --token ", - "Authentication token" - ).env("VAULTLINK_TOKEN") + new Option("-t, --token ", "Authentication token").env( + "VAULTLINK_TOKEN" + ) ) .addOption( - new Option( - "-v, --vault-name ", - "Vault name" - ).env("VAULTLINK_VAULT_NAME") + new Option("-v, --vault-name ", "Vault name").env( + "VAULTLINK_VAULT_NAME" + ) ) .addOption( new Option( @@ -147,10 +144,7 @@ Environment variables: const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto"; /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ - const requireOption = ( - value: T | undefined, - name: string - ): T => { + const requireOption = (value: T | undefined, name: string): T => { if (value === undefined) { const option = program.options.find( (o) => o.attributeName() === name @@ -173,9 +167,7 @@ Environment variables: // Validate remote URI protocol if ( - !VALID_PROTOCOLS.some((prefix) => - requiredRemoteUri.startsWith(prefix) - ) + !VALID_PROTOCOLS.some((prefix) => requiredRemoteUri.startsWith(prefix)) ) { throw new Error( `Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_PROTOCOLS.join(", ")}` diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index 1a8b1e83..e06fda47 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -50,9 +50,7 @@ function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void { const HEALTH_CHECK_INTERVAL_MS = 30 * 1000; const PROGRESS_LOG_INTERVAL_MS = 2000; -function resolveLineEndings( - mode: "auto" | "lf" | "crlf" -): string { +function resolveLineEndings(mode: "auto" | "lf" | "crlf"): string { switch (mode) { case "lf": return "\n"; @@ -94,9 +92,7 @@ async function main(): Promise { logger.info(`Remote URI: ${args.remoteUri}`); logger.info(`Vault name: ${args.vaultName}`); if (args.lineEndings !== "auto") { - logger.info( - `Line endings: ${args.lineEndings.toUpperCase()}` - ); + logger.info(`Line endings: ${args.lineEndings.toUpperCase()}`); } } @@ -138,9 +134,7 @@ async function main(): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion database = JSON.parse(content) as Partial; } catch { - logger.warn( - `Cannot read data file at ${dataFile}` - ); + logger.warn(`Cannot read data file at ${dataFile}`); } return { @@ -225,9 +219,7 @@ async function main(): Promise { } isShuttingDown = true; - client.logger.info( - `${signal} received, shutting down gracefully` - ); + client.logger.info(`${signal} received, shutting down gracefully`); fileWatcher.stop(); await client.waitUntilFinished(); diff --git a/frontend/local-client-cli/src/file-watcher.ts b/frontend/local-client-cli/src/file-watcher.ts index 2e70df02..c273a412 100644 --- a/frontend/local-client-cli/src/file-watcher.ts +++ b/frontend/local-client-cli/src/file-watcher.ts @@ -28,8 +28,7 @@ export class FileWatcher { renameDetection: true, renameTimeout: 125, ignoreInitial: true, - ignore: (filePath: string): boolean => - this.shouldIgnore(filePath) + ignore: (filePath: string): boolean => this.shouldIgnore(filePath) }); this.watcher.on("add", (filePath: string) => { @@ -91,8 +90,4 @@ export class FileWatcher { private toRelativePath(absolutePath: string): RelativePath { return toUnixPath(path.relative(this.basePath, absolutePath)); } - - private formatError(err: unknown): string { - return err instanceof Error ? err.message : String(err); - } } diff --git a/frontend/local-client-cli/src/node-filesystem.ts b/frontend/local-client-cli/src/node-filesystem.ts index f84cbdb8..7b736c22 100644 --- a/frontend/local-client-cli/src/node-filesystem.ts +++ b/frontend/local-client-cli/src/node-filesystem.ts @@ -15,18 +15,12 @@ export class NodeFileSystemOperations implements FileSystemOperations { directory: RelativePath | undefined ): Promise { const files: RelativePath[] = []; - await this.walkDirectory( - directory ?? "", - files - ); + await this.walkDirectory(directory ?? "", files); return files; } public async read(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - relativePath - ); + const fullPath = path.join(this.basePath, relativePath); try { return await fs.readFile(fullPath); } catch (error) { @@ -40,10 +34,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { relativePath: RelativePath, content: Uint8Array ): Promise { - const fullPath = path.join( - this.basePath, - relativePath - ); + const fullPath = path.join(this.basePath, relativePath); const dir = path.dirname(fullPath); try { @@ -60,10 +51,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { relativePath: RelativePath, updater: (current: TextWithCursors) => TextWithCursors ): Promise { - const fullPath = path.join( - this.basePath, - relativePath - ); + const fullPath = path.join(this.basePath, relativePath); try { const currentContent = await fs.readFile(fullPath, "utf-8"); @@ -78,10 +66,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { } public async getFileSize(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - relativePath - ); + const fullPath = path.join(this.basePath, relativePath); try { const stats = await fs.stat(fullPath); return stats.size; @@ -93,10 +78,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { } public async exists(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - relativePath - ); + const fullPath = path.join(this.basePath, relativePath); try { await fs.access(fullPath); return true; @@ -106,10 +88,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { } public async createDirectory(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - relativePath - ); + const fullPath = path.join(this.basePath, relativePath); try { await fs.mkdir(fullPath, { recursive: false }); } catch (error) { @@ -120,10 +99,7 @@ export class NodeFileSystemOperations implements FileSystemOperations { } public async delete(relativePath: RelativePath): Promise { - const fullPath = path.join( - this.basePath, - relativePath - ); + const fullPath = path.join(this.basePath, relativePath); try { await fs.unlink(fullPath); } catch (error) { @@ -191,5 +167,4 @@ export class NodeFileSystemOperations implements FileSystemOperations { } } } - } diff --git a/frontend/local-client-cli/src/path-utils.ts b/frontend/local-client-cli/src/path-utils.ts index a781b746..dd89fa67 100644 --- a/frontend/local-client-cli/src/path-utils.ts +++ b/frontend/local-client-cli/src/path-utils.ts @@ -8,10 +8,7 @@ export function toUnixPath(nativePath: string): string { // Match a file path against a glob pattern // Extends path.matchesGlob so that "dir/**" also matches the directory itself export function matchesGlob(filePath: string, pattern: string): boolean { - if ( - pattern.endsWith("/**") && - filePath === pattern.slice(0, -3) - ) { + if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) { return true; } return path.matchesGlob(filePath, pattern); diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index 0291e646..e222796b 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -231,9 +231,9 @@ export default class VaultLinkPlugin extends Plugin { } } ), - this.app.vault.on("create", async (file: TAbstractFile) => { + this.app.vault.on("create", (file: TAbstractFile) => { if (file instanceof TFile) { - await client.syncLocallyCreatedFile(file.path); + client.syncLocallyCreatedFile(file.path); } }), this.app.vault.on("modify", async (file: TAbstractFile) => { @@ -241,14 +241,14 @@ export default class VaultLinkPlugin extends Plugin { await this.rateLimitedUpdate(file.path, client); } }), - this.app.vault.on("delete", async (file: TAbstractFile) => { - await client.syncLocallyDeletedFile(file.path); + this.app.vault.on("delete", (file: TAbstractFile) => { + client.syncLocallyDeletedFile(file.path); }), this.app.vault.on( "rename", - async (file: TAbstractFile, oldPath: string) => { + (file: TAbstractFile, oldPath: string) => { if (file instanceof TFile) { - await client.syncLocallyUpdatedFile({ + client.syncLocallyUpdatedFile({ oldPath, relativePath: file.path }); @@ -267,13 +267,11 @@ export default class VaultLinkPlugin extends Plugin { if (!this.rateLimitedUpdatesPerFile.has(path)) { this.rateLimitedUpdatesPerFile.set( path, - rateLimit( - async () => - { client.syncLocallyUpdatedFile({ - relativePath: path - }); }, - MIN_WAIT_BETWEEN_UPDATES_IN_MS - ) + rateLimit(async () => { + client.syncLocallyUpdatedFile({ + relativePath: path + }); + }, MIN_WAIT_BETWEEN_UPDATES_IN_MS) ); } await this.rateLimitedUpdatesPerFile.get(path)?.(); diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index 5d1129db..12e3777d 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -8,6 +8,7 @@ import type { FileSystemOperations } from "./filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; import type { ServerConfig, ServerConfigData } from "../services/server-config"; import { CONFLICT_PATH_REGEX } from "../sync-operations/conflict-path"; +import { removeFromArray } from "../utils/remove-from-array"; class MockServerConfig implements Pick { public async getConfig(): Promise { @@ -81,9 +82,7 @@ function singleConflictPath( expectedNonConflictNames: string[] ): string { const expected = new Set(expectedNonConflictNames); - const conflicts = Array.from(names).filter( - (name) => !expected.has(name) - ); + const conflicts = Array.from(names).filter((name) => !expected.has(name)); assert.equal( conflicts.length, 1, @@ -139,7 +138,11 @@ describe("File operations", () => { it("move with EXISTING displaces the target to a conflict path", async () => { const { fs, ops } = makeOps(); - await ops.create("source.md", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.create( + "source.md", + new Uint8Array(), + MoveOnConflict.EXISTING + ); await ops.create("dest.md", new Uint8Array(), MoveOnConflict.EXISTING); await ops.move("source.md", "dest.md", MoveOnConflict.EXISTING); @@ -156,7 +159,11 @@ describe("File operations", () => { it("move with NEW redirects the moved file to a conflict path", async () => { const { fs, ops } = makeOps(); - await ops.create("source.md", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.create( + "source.md", + new Uint8Array(), + MoveOnConflict.EXISTING + ); await ops.create("dest.md", new Uint8Array(), MoveOnConflict.EXISTING); await ops.move("source.md", "dest.md", MoveOnConflict.NEW); @@ -190,7 +197,11 @@ describe("File operations", () => { it("handles dotfiles without mangling the extension", async () => { const { fs, ops } = makeOps(); - await ops.create(".gitignore", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.create( + ".gitignore", + new Uint8Array(), + MoveOnConflict.EXISTING + ); await ops.create("temp", new Uint8Array(), MoveOnConflict.EXISTING); await ops.move("temp", ".gitignore", MoveOnConflict.EXISTING); @@ -200,7 +211,11 @@ describe("File operations", () => { `conflict should preserve the dotfile name verbatim, got ${conflict}` ); - await ops.create(".config.json", new Uint8Array(), MoveOnConflict.EXISTING); + await ops.create( + ".config.json", + new Uint8Array(), + MoveOnConflict.EXISTING + ); await ops.create("temp2", new Uint8Array(), MoveOnConflict.EXISTING); await ops.move("temp2", ".config.json", MoveOnConflict.EXISTING); @@ -221,7 +236,8 @@ describe("File operations", () => { await ops.create("x", new Uint8Array(), MoveOnConflict.EXISTING); await ops.create("x", new Uint8Array(), MoveOnConflict.EXISTING); - const conflicts = Array.from(fs.names).filter((n) => n !== "x"); + const conflicts = Array.from(fs.names); + removeFromArray(conflicts, "x"); assert.equal(conflicts.length, 2); assert.ok(conflicts.every((c) => CONFLICT_PATH_REGEX.test(c))); assert.notEqual( diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 5384768d..29e9f0b6 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -1,7 +1,6 @@ import type { Logger } from "../tracing/logger"; import type { FileSystemOperations } from "./filesystem-operations"; import type { RelativePath } from "../sync-operations/types"; -import type { SyncEventQueue } from "../sync-operations/sync-event-queue"; import { SafeFileSystemOperations } from "./safe-filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; import { reconcile } from "reconcile-text"; @@ -10,10 +9,9 @@ import { isBinary } from "../utils/is-binary"; import { buildConflictFileName } from "../sync-operations/conflict-path"; import type { ServerConfig } from "../services/server-config"; - export enum MoveOnConflict { EXISTING = "EXISTING", - NEW = "NEW", + NEW = "NEW" } export class FileOperations { @@ -40,6 +38,17 @@ export class FileOperations { return [pathParts.join("/"), fileName]; } + /** + * Build a local-only conflict path for a file the client has to set aside. + * Format: `/conflict--` — UUID makes collisions + * statistically impossible, so no disk probe / lock dance is needed. + */ + private static buildConflictPath(path: RelativePath): RelativePath { + const [directory, fileName] = FileOperations.getParentDirAndFile(path); + const conflictName = buildConflictFileName(fileName); + return directory ? `${directory}/${conflictName}` : conflictName; + } + public async listFilesRecursively( root: RelativePath | undefined = undefined ): Promise { @@ -55,7 +64,7 @@ export class FileOperations { * * If a file with the same name already exists, it is moved before creating the new one. * Parent directories are created if necessary. - * + * * Returns the actual path the file was created at. */ public async create( @@ -68,30 +77,6 @@ export class FileOperations { return actualPath; } - private async ensureClearPath( - path: RelativePath, - moveOnConflict: MoveOnConflict - ): Promise { - if (await this.fs.exists(path)) { - const conflictPath = FileOperations.buildConflictPath(path); - - if (moveOnConflict === MoveOnConflict.NEW) { - return conflictPath; - } - - this.logger.debug( - `Displacing existing file at ${path} to '${conflictPath}' to make room` - ); - - await this.fs.rename(path, conflictPath); - return path; - } - - this.logger.debug(`No existing file at ${path}, creating parent directories if needed`); - await this.createParentDirectories(path); - return path; - } - /** * Update the file at the given path. * @@ -129,8 +114,8 @@ export class FileOperations { return; } - let expectedText: string; - let newText: string; + let expectedText = ""; + let newText = ""; try { expectedText = new TextDecoder("utf-8", { fatal: true }).decode( expectedContent @@ -206,6 +191,31 @@ export class FileOperations { return actualPath; } + private async ensureClearPath( + path: RelativePath, + moveOnConflict: MoveOnConflict + ): Promise { + if (await this.fs.exists(path)) { + const conflictPath = FileOperations.buildConflictPath(path); + + if (moveOnConflict === MoveOnConflict.NEW) { + return conflictPath; + } + + this.logger.debug( + `Displacing existing file at ${path} to '${conflictPath}' to make room` + ); + + await this.fs.rename(path, conflictPath); + return path; + } + + this.logger.debug( + `No existing file at ${path}, creating parent directories if needed` + ); + await this.createParentDirectories(path); + return path; + } private async deletingEmptyParentDirectoriesOfDeletedFile( path: RelativePath @@ -265,16 +275,4 @@ export class FileOperations { } } } - - /** - * Build a local-only conflict path for a file the client has to set aside. - * Format: `/conflict--` — UUID makes collisions - * statistically impossible, so no disk probe / lock dance is needed. - */ - private static buildConflictPath(path: RelativePath): RelativePath { - const [directory, fileName] = - FileOperations.getParentDirAndFile(path); - const conflictName = buildConflictFileName(fileName); - return directory ? `${directory}/${conflictName}` : conflictName; - } } diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts index da69f446..c79ace63 100644 --- a/frontend/sync-client/src/index.ts +++ b/frontend/sync-client/src/index.ts @@ -22,7 +22,11 @@ export { export { Logger, LogLevel, LogLine } from "./tracing/logger"; export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings"; export { rateLimit } from "./utils/rate-limit"; -export type { RelativePath, StoredSyncState as StoredDatabase, DocumentRecord } from "./sync-operations/types"; +export type { + RelativePath, + StoredSyncState as StoredDatabase, + DocumentRecord +} from "./sync-operations/types"; export type { FileSystemOperations } from "./file-operations/filesystem-operations"; export type { PersistenceProvider } from "./persistence/persistence"; export type { CursorSpan } from "./services/types/CursorSpan"; diff --git a/frontend/sync-client/src/services/fetch-controller.ts b/frontend/sync-client/src/services/fetch-controller.ts index cf857dcd..f5bb8664 100644 --- a/frontend/sync-client/src/services/fetch-controller.ts +++ b/frontend/sync-client/src/services/fetch-controller.ts @@ -19,7 +19,11 @@ export class FetchController { private _canFetch: boolean, private readonly logger: Logger ) { - ({ promise: this.until, resolve: this.resolveUntil, reject: this.rejectUntil } = Promise.withResolvers()); + ({ + promise: this.until, + resolve: this.resolveUntil, + reject: this.rejectUntil + } = Promise.withResolvers()); } /** @@ -40,7 +44,11 @@ export class FetchController { if (!this.isResetting) { const previousResolve = this.resolveUntil; - ({ promise: this.until, resolve: this.resolveUntil, reject: this.rejectUntil } = Promise.withResolvers()); + ({ + promise: this.until, + resolve: this.resolveUntil, + reject: this.rejectUntil + } = Promise.withResolvers()); previousResolve(FetchController.UNTIL_RESOLUTION); } } @@ -78,7 +86,11 @@ export class FetchController { } this.isResetting = false; - ({ promise: this.until, resolve: this.resolveUntil, reject: this.rejectUntil } = Promise.withResolvers()); + ({ + promise: this.until, + resolve: this.resolveUntil, + reject: this.rejectUntil + } = Promise.withResolvers()); } /** diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index 873783c3..65726d73 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -66,6 +66,42 @@ export class SyncService { return result; } + private static async throwIfNotOk( + response: Response, + operation: string + ): Promise { + if (response.ok) return; + const message = `Failed to ${operation}: ${await SyncService.errorFromResponse(response)}`; + // 429 is the only 4xx the server uses for *transient* contention + // (`WriteBusyError` → HTTP 429). Every other 4xx means the request + // is permanently rejected and shouldn't be retried. + if (response.status === 429) { + throw new Error(message); + } + if (response.status >= 400 && response.status < 500) { + throw new HttpClientError(response.status, message); + } + throw new Error(message); + } + + /** + * Signal that the service is shutting down so any in-flight + * `retryForever` exits at its next iteration instead of looping + * indefinitely after the rest of the client has stopped. Idempotent. + */ + public stop(): void { + this.isStopped = true; + } + + /** + * Re-enable the service after a `stop()`. Used when the client pauses + * and resumes syncing within the same lifecycle (e.g. user toggles + * sync off and on). + */ + public resume(): void { + this.isStopped = false; + } + public async create({ relativePath, lastSeenVaultUpdateId, @@ -146,8 +182,7 @@ export class SyncService { (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion this.logger.debug( - `Updated document ${JSON.stringify(result)} with id ${ - result.documentId + `Updated document ${JSON.stringify(result)} with id ${result.documentId }}` ); @@ -193,8 +228,7 @@ export class SyncService { (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion this.logger.debug( - `Updated document ${JSON.stringify(result)} with id ${ - result.documentId + `Updated document ${JSON.stringify(result)} with id ${result.documentId }}` ); @@ -284,7 +318,10 @@ export class SyncService { } ); - await SyncService.throwIfNotOk(response, "get document version content"); + await SyncService.throwIfNotOk( + response, + "get document version content" + ); const result = await response.bytes(); this.logger.debug( @@ -300,7 +337,7 @@ export class SyncService { return this.retryForever(async () => { this.logger.debug( "Getting all documents" + - (since != null ? ` since ${since}` : "") + (since != null ? ` since ${since}` : "") ); const url = new URL(this.getUrl("/documents")); @@ -369,30 +406,10 @@ export class SyncService { return headers; } - /** - * Signal that the service is shutting down so any in-flight - * `retryForever` exits at its next iteration instead of looping - * indefinitely after the rest of the client has stopped. Idempotent. - */ - public stop(): void { - this.isStopped = true; - } - - /** - * Re-enable the service after a `stop()`. Used when the client pauses - * and resumes syncing within the same lifecycle (e.g. user toggles - * sync off and on). - */ - public resume(): void { - this.isStopped = false; - } - private async retryForever(fn: () => Promise): Promise { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { - if (this.isStopped) { - throw new SyncResetError(); - } + this.throwIfStopped(); try { return await fn(); } catch (e) { @@ -402,9 +419,7 @@ export class SyncService { ) { throw e; } - if (this.isStopped) { - throw new SyncResetError(); - } + this.throwIfStopped(); const retryInterval = this.settings.getSettings().networkRetryIntervalMs; @@ -416,21 +431,9 @@ export class SyncService { } } - private static async throwIfNotOk( - response: Response, - operation: string - ): Promise { - if (response.ok) return; - const message = `Failed to ${operation}: ${await SyncService.errorFromResponse(response)}`; - // 429 is the only 4xx the server uses for *transient* contention - // (`WriteBusyError` → HTTP 429). Every other 4xx means the request - // is permanently rejected and shouldn't be retried. - if (response.status === 429) { - throw new Error(message); + private throwIfStopped(): void { + if (this.isStopped) { + throw new SyncResetError(); } - if (response.status >= 400 && response.status < 500) { - throw new HttpClientError(response.status, message); - } - throw new Error(message); } } diff --git a/frontend/sync-client/src/services/types/ClientCursors.ts b/frontend/sync-client/src/services/types/ClientCursors.ts index 5b1ec040..e8c9b93d 100644 --- a/frontend/sync-client/src/services/types/ClientCursors.ts +++ b/frontend/sync-client/src/services/types/ClientCursors.ts @@ -1,4 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DocumentWithCursors } from "./DocumentWithCursors"; -export interface ClientCursors { userName: string, deviceId: string, documentsWithCursors: DocumentWithCursors[], } +export interface ClientCursors { + userName: string; + deviceId: string; + documentsWithCursors: DocumentWithCursors[]; +} diff --git a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts index 4d1b324e..2d83cd99 100644 --- a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface CreateDocumentVersion { relative_path: string, last_seen_vault_update_id: number, content: number[], } +export interface CreateDocumentVersion { + relative_path: string; + last_seen_vault_update_id: number; + content: number[]; +} diff --git a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts index 78823b5d..ee937f4e 100644 --- a/frontend/sync-client/src/services/types/CursorPositionFromClient.ts +++ b/frontend/sync-client/src/services/types/CursorPositionFromClient.ts @@ -1,4 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DocumentWithCursors } from "./DocumentWithCursors"; -export interface CursorPositionFromClient { documentsWithCursors: DocumentWithCursors[], } +export interface CursorPositionFromClient { + documentsWithCursors: DocumentWithCursors[]; +} diff --git a/frontend/sync-client/src/services/types/CursorPositionFromServer.ts b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts index ed6ac7b2..52a24f27 100644 --- a/frontend/sync-client/src/services/types/CursorPositionFromServer.ts +++ b/frontend/sync-client/src/services/types/CursorPositionFromServer.ts @@ -1,4 +1,6 @@ // 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 interface CursorPositionFromServer { clients: ClientCursors[], } +export interface CursorPositionFromServer { + clients: ClientCursors[]; +} diff --git a/frontend/sync-client/src/services/types/CursorSpan.ts b/frontend/sync-client/src/services/types/CursorSpan.ts index 7424067c..2cc2b7fc 100644 --- a/frontend/sync-client/src/services/types/CursorSpan.ts +++ b/frontend/sync-client/src/services/types/CursorSpan.ts @@ -1,3 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface CursorSpan { start: number, end: number, } +export interface CursorSpan { + start: number; + end: number; +} diff --git a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts index 51e0b37c..dd7eadda 100644 --- a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts +++ b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts @@ -5,4 +5,6 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a create/update document request. */ -export type DocumentUpdateResponse = { "type": "FastForwardUpdate" } & DocumentVersionWithoutContent | { "type": "MergingUpdate" } & DocumentVersion; +export type DocumentUpdateResponse = + | ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent) + | ({ type: "MergingUpdate" } & DocumentVersion); diff --git a/frontend/sync-client/src/services/types/DocumentVersion.ts b/frontend/sync-client/src/services/types/DocumentVersion.ts index 3d50ae65..3b9aa37b 100644 --- a/frontend/sync-client/src/services/types/DocumentVersion.ts +++ b/frontend/sync-client/src/services/types/DocumentVersion.ts @@ -1,3 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface DocumentVersion { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, contentBase64: string, isDeleted: boolean, userId: string, deviceId: string, } +export interface DocumentVersion { + vaultUpdateId: number; + documentId: string; + relativePath: string; + updatedDate: string; + contentBase64: string; + isDeleted: boolean; + userId: string; + deviceId: string; +} diff --git a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts index af064db8..4b24e7c5 100644 --- a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts +++ b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts @@ -1,3 +1,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface DocumentVersionWithoutContent { vaultUpdateId: number, documentId: string, relativePath: string, updatedDate: string, isDeleted: boolean, userId: string, deviceId: string, contentSize: number, } +export interface DocumentVersionWithoutContent { + vaultUpdateId: number; + documentId: string; + relativePath: string; + updatedDate: string; + isDeleted: boolean; + userId: string; + deviceId: string; + contentSize: number; +} diff --git a/frontend/sync-client/src/services/types/DocumentWithCursors.ts b/frontend/sync-client/src/services/types/DocumentWithCursors.ts index d29b3f79..8ed59067 100644 --- a/frontend/sync-client/src/services/types/DocumentWithCursors.ts +++ b/frontend/sync-client/src/services/types/DocumentWithCursors.ts @@ -1,4 +1,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { CursorSpan } from "./CursorSpan"; -export interface DocumentWithCursors { vaultUpdateId: number | null, documentId: string, relativePath: string, cursors: CursorSpan[], } +export interface DocumentWithCursors { + vaultUpdateId: number | null; + documentId: string; + relativePath: string; + cursors: CursorSpan[]; +} diff --git a/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts index 3be625bd..315d701a 100644 --- a/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts +++ b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts @@ -4,8 +4,10 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a fetch latest documents request. */ -export interface FetchLatestDocumentsResponse { latestDocuments: DocumentVersionWithoutContent[], -/** - * The update ID of the latest document in the response. - */ -lastUpdateId: bigint, } +export interface FetchLatestDocumentsResponse { + latestDocuments: DocumentVersionWithoutContent[]; + /** + * The update ID of the latest document in the response. + */ + lastUpdateId: bigint; +} diff --git a/frontend/sync-client/src/services/types/ListVaultsResponse.ts b/frontend/sync-client/src/services/types/ListVaultsResponse.ts index 85928d89..babad2d5 100644 --- a/frontend/sync-client/src/services/types/ListVaultsResponse.ts +++ b/frontend/sync-client/src/services/types/ListVaultsResponse.ts @@ -4,4 +4,8 @@ import type { VaultInfo } from "./VaultInfo"; /** * Response to listing vaults accessible to the authenticated user. */ -export interface ListVaultsResponse { vaults: VaultInfo[], hasMore: boolean, userName: string, } +export interface ListVaultsResponse { + vaults: VaultInfo[]; + hasMore: boolean; + userName: string; +} diff --git a/frontend/sync-client/src/services/types/PingResponse.ts b/frontend/sync-client/src/services/types/PingResponse.ts index ba8ceb48..f96520e9 100644 --- a/frontend/sync-client/src/services/types/PingResponse.ts +++ b/frontend/sync-client/src/services/types/PingResponse.ts @@ -3,22 +3,23 @@ /** * Response to a ping request. */ -export interface 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: string[], -/** - * API version ensuring backwards & forwards compatibility between the client - * and server. - */ -supportedApiVersion: number, } +export interface 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: string[]; + /** + * API version ensuring backwards & forwards compatibility between the client + * and server. + */ + supportedApiVersion: number; +} diff --git a/frontend/sync-client/src/services/types/SerializedError.ts b/frontend/sync-client/src/services/types/SerializedError.ts index 4389289e..ec1c4503 100644 --- a/frontend/sync-client/src/services/types/SerializedError.ts +++ b/frontend/sync-client/src/services/types/SerializedError.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface SerializedError { errorType: string, message: string, causes: string[], } +export interface SerializedError { + errorType: string; + message: string; + causes: string[]; +} diff --git a/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts b/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts index aeb69f5a..46f36bd0 100644 --- a/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface UpdateTextDocumentVersion { parentVersionId: number, relativePath: string, content: (number | string)[], } +export interface UpdateTextDocumentVersion { + parentVersionId: number; + relativePath: string; + content: (number | string)[]; +} diff --git a/frontend/sync-client/src/services/types/VaultHistoryResponse.ts b/frontend/sync-client/src/services/types/VaultHistoryResponse.ts index 93d6ec6c..35531010 100644 --- a/frontend/sync-client/src/services/types/VaultHistoryResponse.ts +++ b/frontend/sync-client/src/services/types/VaultHistoryResponse.ts @@ -4,4 +4,7 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont /** * Response to a vault history request (paginated). */ -export interface VaultHistoryResponse { versions: DocumentVersionWithoutContent[], hasMore: boolean, } +export interface VaultHistoryResponse { + versions: DocumentVersionWithoutContent[]; + hasMore: boolean; +} diff --git a/frontend/sync-client/src/services/types/VaultInfo.ts b/frontend/sync-client/src/services/types/VaultInfo.ts index 921645f3..20d6811c 100644 --- a/frontend/sync-client/src/services/types/VaultInfo.ts +++ b/frontend/sync-client/src/services/types/VaultInfo.ts @@ -3,4 +3,8 @@ /** * Summary of a single vault returned by the list-vaults endpoint. */ -export interface VaultInfo { name: string, documentCount: number, createdAt: string | null, } +export interface VaultInfo { + name: string; + documentCount: number; + createdAt: string | null; +} diff --git a/frontend/sync-client/src/services/types/WebSocketClientMessage.ts b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts index 5765a0d0..9608f3af 100644 --- a/frontend/sync-client/src/services/types/WebSocketClientMessage.ts +++ b/frontend/sync-client/src/services/types/WebSocketClientMessage.ts @@ -2,4 +2,6 @@ import type { CursorPositionFromClient } from "./CursorPositionFromClient"; import type { WebSocketHandshake } from "./WebSocketHandshake"; -export type WebSocketClientMessage = { "type": "handshake" } & WebSocketHandshake | { "type": "cursorPositions" } & CursorPositionFromClient; +export type WebSocketClientMessage = + | ({ type: "handshake" } & WebSocketHandshake) + | ({ type: "cursorPositions" } & CursorPositionFromClient); diff --git a/frontend/sync-client/src/services/types/WebSocketHandshake.ts b/frontend/sync-client/src/services/types/WebSocketHandshake.ts index d25651f9..a2910f49 100644 --- a/frontend/sync-client/src/services/types/WebSocketHandshake.ts +++ b/frontend/sync-client/src/services/types/WebSocketHandshake.ts @@ -1,3 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface WebSocketHandshake { token: string, deviceId: string, lastSeenVaultUpdateId: number | null, } +export interface WebSocketHandshake { + token: string; + deviceId: string; + lastSeenVaultUpdateId: number | null; +} diff --git a/frontend/sync-client/src/services/types/WebSocketServerMessage.ts b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts index 45e37358..fd250b7b 100644 --- a/frontend/sync-client/src/services/types/WebSocketServerMessage.ts +++ b/frontend/sync-client/src/services/types/WebSocketServerMessage.ts @@ -2,4 +2,6 @@ import type { CursorPositionFromServer } from "./CursorPositionFromServer"; import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; -export type WebSocketServerMessage = { "type": "vaultUpdate" } & WebSocketVaultUpdate | { "type": "cursorPositions" } & CursorPositionFromServer; +export type WebSocketServerMessage = + | ({ type: "vaultUpdate" } & WebSocketVaultUpdate) + | ({ type: "cursorPositions" } & CursorPositionFromServer); diff --git a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts index 5e7df8a5..b4a942c8 100644 --- a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts +++ b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts @@ -1,4 +1,6 @@ // 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 interface WebSocketVaultUpdate { document: DocumentVersionWithoutContent, } +export interface WebSocketVaultUpdate { + document: DocumentVersionWithoutContent; +} diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index 970defb3..5279d0e6 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -58,8 +58,10 @@ export class WebSocketManager { } public async stop(): Promise { - const { promise, resolve } = Promise.withResolvers(); - this.resolveDisconnectingPromise = resolve; + const { promise, resolve } = Promise.withResolvers(); + this.resolveDisconnectingPromise = (): void => { + resolve(undefined); + }; this.isStopped = true; diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 902c7b26..9c919354 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -46,7 +46,6 @@ export class SyncClient { private readonly cursorTracker: CursorTracker, private readonly fileChangeNotifier: FileChangeNotifier, private readonly contentCache: FixedSizeDocumentCache, - private readonly fileOperations: FileOperations, private readonly serverConfig: ServerConfig, private readonly syncService: SyncService, private readonly persistence: PersistenceProvider< @@ -100,6 +99,13 @@ export class SyncClient { return this.cursorTracker.onRemoteCursorsUpdated; } + public get hasPendingWork(): boolean { + return ( + this.syncEventQueue.pendingUpdateCount > 0 || + this.webSocketManager.hasOutstandingWork + ); + } + public static async create({ fs, persistence, @@ -219,7 +225,6 @@ export class SyncClient { cursorTracker, fileChangeNotifier, contentCache, - fileOperations, serverConfig, syncService, persistence @@ -323,7 +328,7 @@ export class SyncClient { await this.pause(); this.logger.info("Resetting SyncClient's local state"); - this.syncEventQueue.clearAllState(); + await this.syncEventQueue.clearAllState(); await this.syncEventQueue.save(); this.resetInMemoryState(); this.hasFinishedOfflineSync = false; @@ -353,18 +358,14 @@ export class SyncClient { await this.settings.setSettings(value); } - public syncLocallyCreatedFile( - relativePath: RelativePath - ): void { + public syncLocallyCreatedFile(relativePath: RelativePath): void { this.checkIfDestroyed("syncLocallyCreatedFile"); this.fileChangeNotifier.notifyOfFileChange(relativePath); this.syncer.syncLocallyCreatedFile(relativePath); } - public syncLocallyDeletedFile( - relativePath: RelativePath - ): void { + public syncLocallyDeletedFile(relativePath: RelativePath): void { this.checkIfDestroyed("syncLocallyDeletedFile"); this.fileChangeNotifier.notifyOfFileChange(relativePath); @@ -387,13 +388,6 @@ export class SyncClient { }); } - public get hasPendingWork(): boolean { - return ( - this.syncEventQueue.pendingUpdateCount > 0 || - this.webSocketManager.hasOutstandingWork - ); - } - public getDocumentSyncingStatus( relativePath: RelativePath ): DocumentSyncStatus { diff --git a/frontend/sync-client/src/sync-operations/conflict-path.test.ts b/frontend/sync-client/src/sync-operations/conflict-path.test.ts index 7f7bf67c..b27f2a0e 100644 --- a/frontend/sync-client/src/sync-operations/conflict-path.test.ts +++ b/frontend/sync-client/src/sync-operations/conflict-path.test.ts @@ -31,10 +31,7 @@ describe("buildConflictFileName", () => { 0, "stem length must be a whole number of families" ); - assert.ok( - !stem.endsWith("‍"), - "stem must not end with a dangling ZWJ" - ); + assert.ok(!stem.endsWith("‍"), "stem must not end with a dangling ZWJ"); }); it("does not split a base character from its combining mark", () => { @@ -61,7 +58,10 @@ describe("buildConflictFileName", () => { describe("CONFLICT_PATH_REGEX", () => { it("does not misclassify user-authored names that start with `conflict-`", () => { - assert.strictEqual(CONFLICT_PATH_REGEX.test("conflict-resolution.md"), false); + assert.strictEqual( + CONFLICT_PATH_REGEX.test("conflict-resolution.md"), + false + ); }); it("only inspects the final path segment", () => { @@ -80,6 +80,9 @@ describe("CONFLICT_PATH_REGEX", () => { }); it("round-trips with buildConflictFileName", () => { - assert.strictEqual(CONFLICT_PATH_REGEX.test(buildConflictFileName("note.md")), true); + assert.strictEqual( + CONFLICT_PATH_REGEX.test(buildConflictFileName("note.md")), + true + ); }); }); diff --git a/frontend/sync-client/src/sync-operations/conflict-path.ts b/frontend/sync-client/src/sync-operations/conflict-path.ts index 69942750..adc1bea1 100644 --- a/frontend/sync-client/src/sync-operations/conflict-path.ts +++ b/frontend/sync-client/src/sync-operations/conflict-path.ts @@ -8,16 +8,10 @@ export const CONFLICT_PATH_REGEX = /(?:^|\/)conflict-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-[^/]*$/u; - const CONFLICT_PREFIX_LEN = "conflict-".length + 36 + 1; const MAX_SEGMENT_BYTES = 255; const MAX_ORIGINAL_BYTES = MAX_SEGMENT_BYTES - CONFLICT_PREFIX_LEN - 4; -export function buildConflictFileName(fileName: string): string { - const safeName = truncateFileNameToByteLimit(fileName, MAX_ORIGINAL_BYTES); - return `conflict-${crypto.randomUUID()}-${safeName}`; -} - function truncateFileNameToByteLimit( fileName: string, maxBytes: number @@ -34,7 +28,9 @@ function truncateFileNameToByteLimit( const extensionBytes = encoder.encode(extension).byteLength; const stemBudget = Math.max(0, maxBytes - extensionBytes); - const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + const segmenter = new Intl.Segmenter(undefined, { + granularity: "grapheme" + }); let truncatedStem = ""; let usedBytes = 0; for (const { segment } of segmenter.segment(stem)) { @@ -45,3 +41,8 @@ function truncateFileNameToByteLimit( } return truncatedStem + extension; } + +export function buildConflictFileName(fileName: string): string { + const safeName = truncateFileNameToByteLimit(fileName, MAX_ORIGINAL_BYTES); + return `conflict-${crypto.randomUUID()}-${safeName}`; +} diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.ts index 98548f73..a52fea99 100644 --- a/frontend/sync-client/src/sync-operations/cursor-tracker.ts +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.ts @@ -35,7 +35,7 @@ export class CursorTracker { []; public constructor( - private readonly logger: Logger, + logger: Logger, private readonly queue: SyncEventQueue, private readonly webSocketManager: WebSocketManager, private readonly fileOperations: FileOperations, @@ -82,8 +82,7 @@ export class CursorTracker { for (const clientCursor of this.knownRemoteCursors) { if ( clientCursor.documentsWithCursors.some( - (document) => - document.relativePath === relativePath + (document) => document.relativePath === relativePath ) ) { clientCursor.upToDateness = @@ -135,7 +134,9 @@ export class CursorTracker { const readContent = await this.fileOperations.read( doc.relativePath ); - const record = this.queue.getSettledDocumentByPath(doc.relativePath); + const record = this.queue.getSettledDocumentByPath( + doc.relativePath + ); if (record?.remoteHash !== (await hash(readContent))) { doc.vaultUpdateId = null; } @@ -221,20 +222,18 @@ export class CursorTracker { private async getDocumentUpToDateness( document: DocumentWithCursors ): Promise { - const record = this.queue.getSettledDocumentByPath(document.relativePath); + const record = this.queue.getSettledDocumentByPath( + document.relativePath + ); if (!record) { // the document of the cursor must be from the future return DocumentUpToDateness.Later; } - if ( - record.parentVersionId < (document.vaultUpdateId ?? 0) - ) { + if (record.parentVersionId < (document.vaultUpdateId ?? 0)) { return DocumentUpToDateness.Later; - } else if ( - (document.vaultUpdateId ?? 0) < record.parentVersionId - ) { + } else if ((document.vaultUpdateId ?? 0) < record.parentVersionId) { // the document of the cursor must be from the past return DocumentUpToDateness.Prior; } @@ -243,7 +242,9 @@ export class CursorTracker { document.relativePath ); - const currentRecord = this.queue.getSettledDocumentByPath(document.relativePath); + const currentRecord = this.queue.getSettledDocumentByPath( + document.relativePath + ); return currentRecord?.remoteHash === (await hash(currentContent)) ? DocumentUpToDateness.UpToDate : DocumentUpToDateness.Prior; diff --git a/frontend/sync-client/src/sync-operations/offline-change-detector.ts b/frontend/sync-client/src/sync-operations/offline-change-detector.ts index 8bb8c27c..1c07ef42 100644 --- a/frontend/sync-client/src/sync-operations/offline-change-detector.ts +++ b/frontend/sync-client/src/sync-operations/offline-change-detector.ts @@ -8,8 +8,6 @@ import { FileNotFoundError } from "../errors/file-not-found-error"; import type { SyncEventQueue } from "./sync-event-queue"; import { removeFromArray } from "../utils/remove-from-array"; - - /** * Scans the local filesystem and the document database to determine * which files were created, updated, moved, or deleted while the @@ -20,8 +18,11 @@ export async function scheduleOfflineChanges( operations: FileOperations, queue: SyncEventQueue, enqueueCreate: (path: RelativePath) => void, - enqueueUpdate: (args: { oldPath?: RelativePath; relativePath: RelativePath }) => void, - enqueueDelete: (path: RelativePath) => void, + enqueueUpdate: (args: { + oldPath?: RelativePath; + relativePath: RelativePath; + }) => void, + enqueueDelete: (path: RelativePath) => void ): Promise { const allLocalFiles = await operations.listFilesRecursively(); logger.info(`Scheduling sync for ${allLocalFiles.length} local files`); @@ -30,19 +31,14 @@ export async function scheduleOfflineChanges( const locallyPossiblyDeletedFiles: DocumentWithPath[] = []; for (const [path, record] of allDocuments.entries()) { - if ( - record !== undefined - ) { - locallyPossiblyDeletedFiles.push({ path, record }); - } + locallyPossiblyDeletedFiles.push({ path, record }); } const locallyPossibleCreatedFiles: RelativePath[] = []; const syncedLocalFiles: RelativePath[] = []; for (const localFile of allLocalFiles) { - if (allDocuments.has(localFile) - ) { + if (allDocuments.has(localFile)) { syncedLocalFiles.push(localFile); } else { locallyPossibleCreatedFiles.push(localFile); @@ -53,19 +49,27 @@ export async function scheduleOfflineChanges( const content = await operations.read(path); const contentHash = await hash(content); - const matchingDeletedFile = await findMatchingFile(contentHash, locallyPossiblyDeletedFiles); + const matchingDeletedFile = await findMatchingFile( + contentHash, + locallyPossiblyDeletedFiles + ); if (matchingDeletedFile !== undefined) { logger.debug( - `File ${path} might have been moved from ${matchingDeletedFile.path} while offline, scheduling sync to move it`, + `File ${path} might have been moved from ${matchingDeletedFile.path} while offline, scheduling sync to move it` ); - enqueueUpdate({ oldPath: matchingDeletedFile.path, relativePath: path }); + enqueueUpdate({ + oldPath: matchingDeletedFile.path, + relativePath: path + }); removeFromArray(locallyPossiblyDeletedFiles, matchingDeletedFile); removeFromArray(locallyPossibleCreatedFiles, path); } } for (const path of locallyPossibleCreatedFiles) { - logger.debug(`File ${path} was created while offline, scheduling sync to create it`); + logger.debug( + `File ${path} was created while offline, scheduling sync to create it` + ); enqueueCreate(path); } diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts index 99b37bf8..c1dbce9e 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts @@ -9,8 +9,12 @@ import type { DocumentRecord, RelativePath } from "./types"; function createQueue(ignorePatterns: string[] = []): SyncEventQueue { const logger = new Logger(); - const settings = new Settings(logger, { ignorePatterns }, async () => { }); - return new SyncEventQueue(settings, logger, undefined, async () => { }); + const settings = new Settings(logger, { ignorePatterns }, async () => { + /* no-op */ + }); + return new SyncEventQueue(settings, logger, undefined, async () => { + /* no-op */ + }); } function fakeRemoteVersion( @@ -60,9 +64,7 @@ describe("SyncEventQueue", () => { const third = await queue.next(); assert.strictEqual(third?.type, SyncEventType.LocalDelete); - if (third?.type === SyncEventType.LocalDelete) { - assert.strictEqual(third.documentId, "A"); - } + assert.strictEqual(third.documentId, "A"); assert.strictEqual(await queue.next(), undefined); }); @@ -74,15 +76,11 @@ describe("SyncEventQueue", () => { const first = await queue.next(); assert.strictEqual(first?.type, SyncEventType.LocalCreate); - if (first?.type === SyncEventType.LocalCreate) { - assert.strictEqual(first.path, "a.md"); - } + assert.strictEqual(first.path, "a.md"); const second = await queue.next(); assert.strictEqual(second?.type, SyncEventType.LocalCreate); - if (second?.type === SyncEventType.LocalCreate) { - assert.strictEqual(second.path, "b.md"); - } + assert.strictEqual(second.path, "b.md"); }); it("delete resolves documentId from path", async () => { @@ -93,14 +91,15 @@ describe("SyncEventQueue", () => { const event = await queue.next(); assert.strictEqual(event?.type, SyncEventType.LocalDelete); - if (event?.type === SyncEventType.LocalDelete) { - assert.strictEqual(event.documentId, "A"); - } + assert.strictEqual(event.documentId, "A"); }); it("delete for unknown path is silently ignored", async () => { const queue = createQueue(); - await queue.enqueue({ type: SyncEventType.LocalDelete, path: "unknown.md" }); + await queue.enqueue({ + type: SyncEventType.LocalDelete, + path: "unknown.md" + }); assert.strictEqual(queue.pendingUpdateCount, 0); }); @@ -112,11 +111,14 @@ describe("SyncEventQueue", () => { await queue.setDocument("a.md", fakeRecord("A")); assert.strictEqual(queue.syncedDocumentCount, 1); - assert.deepStrictEqual(queue.getSettledDocumentByPath("a.md"), fakeRecord("A")); + assert.deepStrictEqual( + queue.getSettledDocumentByPath("a.md"), + fakeRecord("A") + ); const found = queue.getDocumentByDocumentId("A"); assert.strictEqual(found?.path, "a.md"); - assert.strictEqual(found?.record.documentId, "A"); + assert.strictEqual(found.record.documentId, "A"); await queue.removeDocument("a.md"); assert.strictEqual(queue.syncedDocumentCount, 0); @@ -127,9 +129,16 @@ describe("SyncEventQueue", () => { const queue = createQueue(); await queue.setDocument("a.md", fakeRecord("A")); - await queue.enqueue({ type: SyncEventType.LocalUpdate, path: "b.md", oldPath: "a.md" }); + await queue.enqueue({ + type: SyncEventType.LocalUpdate, + path: "b.md", + oldPath: "a.md" + }); assert.strictEqual(queue.getSettledDocumentByPath("a.md"), undefined); - assert.strictEqual(queue.getSettledDocumentByPath("b.md")?.documentId, "A"); + assert.strictEqual( + queue.getSettledDocumentByPath("b.md")?.documentId, + "A" + ); }); it("create can be re-enqueued after being dequeued", async () => { @@ -144,11 +153,20 @@ describe("SyncEventQueue", () => { it("silently ignores create events matching ignore patterns", async () => { const queue = createQueue(["*.tmp", ".hidden/**"]); - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "scratch.tmp" }); - await queue.enqueue({ type: SyncEventType.LocalCreate, path: ".hidden/secret.md" }); + await queue.enqueue({ + type: SyncEventType.LocalCreate, + path: "scratch.tmp" + }); + await queue.enqueue({ + type: SyncEventType.LocalCreate, + path: ".hidden/secret.md" + }); assert.strictEqual(queue.pendingUpdateCount, 0); - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "notes-new.md" }); + await queue.enqueue({ + type: SyncEventType.LocalCreate, + path: "notes-new.md" + }); assert.strictEqual(queue.pendingUpdateCount, 1); await queue.enqueue({ @@ -170,7 +188,10 @@ describe("SyncEventQueue", () => { assert.strictEqual(queue.pendingUpdateCount, 0); assert.strictEqual(queue.syncedDocumentCount, 1); - assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "A"); + assert.strictEqual( + queue.getSettledDocumentByPath("a.md")?.documentId, + "A" + ); }); it("allSettledDocuments returns all tracked documents", async () => { @@ -186,24 +207,39 @@ describe("SyncEventQueue", () => { it("loads initial state from persistence", () => { const logger = new Logger(); - const settings = new Settings(logger, {}, async () => { }); - const queue = new SyncEventQueue(settings, logger, { - documents: [ - { - relativePath: "a.md", - ...fakeRecord("A", { parentVersionId: 5 }) - }, - { - relativePath: "b.md", - ...fakeRecord("B", { parentVersionId: 3 }) - } - ], - lastSeenUpdateId: 4 - }, async () => { }); + const settings = new Settings(logger, {}, async () => { + /* no-op */ + }); + const queue = new SyncEventQueue( + settings, + logger, + { + documents: [ + { + relativePath: "a.md", + ...fakeRecord("A", { parentVersionId: 5 }) + }, + { + relativePath: "b.md", + ...fakeRecord("B", { parentVersionId: 3 }) + } + ], + lastSeenUpdateId: 4 + }, + async () => { + /* no-op */ + } + ); assert.strictEqual(queue.syncedDocumentCount, 2); - assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "A"); - assert.strictEqual(queue.getSettledDocumentByPath("b.md")?.documentId, "B"); + assert.strictEqual( + queue.getSettledDocumentByPath("a.md")?.documentId, + "A" + ); + assert.strictEqual( + queue.getSettledDocumentByPath("b.md")?.documentId, + "B" + ); assert.strictEqual(queue.lastSeenUpdateId, 4); }); @@ -216,10 +252,16 @@ describe("SyncEventQueue", () => { assert.ok(event?.type === SyncEventType.LocalCreate); const createPromise = event.resolvers.promise; - await queue.resolveCreate(event, fakeRecord("DOC-1", { parentVersionId: 5 })); + await queue.resolveCreate( + event, + fakeRecord("DOC-1", { parentVersionId: 5 }) + ); // Document is now settled - assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "DOC-1"); + assert.strictEqual( + queue.getSettledDocumentByPath("a.md")?.documentId, + "DOC-1" + ); // Promise was resolved assert.strictEqual(await createPromise, "DOC-1"); diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index 69856e8d..66ddf7eb 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -3,8 +3,8 @@ import type { Logger } from "../tracing/logger"; import { globsToRegexes } from "../utils/globs-to-regexes"; import { CONFLICT_PATH_REGEX } from "./conflict-path"; import { removeFromArray } from "../utils/remove-from-array"; +import type { DocumentWithPath } from "./types"; import { - DocumentWithPath, SyncEventType, type DocumentId, type DocumentRecord, @@ -12,27 +12,28 @@ import { type RelativePath, type StoredSyncState, type SyncEvent, - type VaultUpdateId, + type VaultUpdateId } from "./types"; import { MinCovered } from "../utils/data-structures/min-covered"; - export class SyncEventQueue { + private _lastSeenUpdateId: MinCovered; + // Latest state of the filesystem as we know it, excluding // unconfirmed creates but including pending deletes. // // It's always indexed by the latest path on disk. - // + // // It maps a subset of the remote state onto the local filesystem. private readonly documents = new Map(); // All outstanding operations in order of occurrence, - // can include multiple generations of the same document, + // can include multiple generations of the same document, // e.g.: a create, delete, create sequence for the same path. // // The paths within the events must always correspond to the latest // path on disk, so the path of each event may be updated multiple - // times. + // times. // // It maps pending changes onto the local filesystem. private readonly events: SyncEvent[] = []; @@ -40,8 +41,6 @@ export class SyncEventQueue { // file creations for paths matching any of these patterns will be ignored private ignorePatterns: RegExp[]; - public _lastSeenUpdateId: MinCovered; - public constructor( private readonly settings: Settings, private readonly logger: Logger, @@ -70,17 +69,13 @@ export class SyncEventQueue { this.documents.set(relativePath, record); } } - this._lastSeenUpdateId = new MinCovered(initialState.lastSeenUpdateId ?? 0); + this._lastSeenUpdateId = new MinCovered( + initialState.lastSeenUpdateId ?? 0 + ); - this.logger.debug(`Loaded ${this.documents.size} documents and lastSeenUpdateId=${this._lastSeenUpdateId} from storage`); - } - - public get lastSeenUpdateId(): VaultUpdateId { - return this._lastSeenUpdateId.min; - } - - public set lastSeenUpdateId(id: VaultUpdateId) { - this._lastSeenUpdateId.add(id); + this.logger.debug( + `Loaded ${this.documents.size} documents and lastSeenUpdateId=${this._lastSeenUpdateId.min} from storage` + ); } public get pendingUpdateCount(): number { @@ -91,8 +86,19 @@ export class SyncEventQueue { return this.documents.size; } + public get lastSeenUpdateId(): VaultUpdateId { + return this._lastSeenUpdateId.min; + } + + public set lastSeenUpdateId(id: VaultUpdateId) { + this._lastSeenUpdateId.add(id); + } + public async enqueue(input: FileSyncEvent): Promise { - const path = (input.type === SyncEventType.RemoteChange) ? input.remoteVersion.relativePath : input.path; + const path = + input.type === SyncEventType.RemoteChange + ? input.remoteVersion.relativePath + : input.path; if (this.ignorePatterns.some((pattern) => pattern.test(path))) { this.logger.info( @@ -106,22 +112,28 @@ export class SyncEventQueue { return; } - if (input.type === SyncEventType.LocalCreate) { - this.events.push({ type: SyncEventType.LocalCreate, path, originalPath: path, resolvers: Promise.withResolvers() }); + this.events.push({ + type: SyncEventType.LocalCreate, + path, + originalPath: path, + resolvers: Promise.withResolvers() + }); return; } - const lookupPath = (input.type === SyncEventType.LocalUpdate && input.oldPath) ? input.oldPath : path; + const lookupPath = + input.type === SyncEventType.LocalUpdate && + input.oldPath !== undefined + ? input.oldPath + : path; const record = this.documents.get(lookupPath); // latest creation must take precedence as it's from the doc's latest generation const pendingDocumentId: Promise | undefined = this.findLatestCreateForPath(lookupPath)?.resolvers.promise; - const documentId: DocumentId | undefined = - record?.documentId; - + const documentId: DocumentId | undefined = record?.documentId; if (pendingDocumentId === undefined && documentId === undefined) { // we can get here when deleting a local document after a remote update @@ -129,7 +141,14 @@ export class SyncEventQueue { } if (input.type === SyncEventType.LocalDelete) { - this.events.push({ type: SyncEventType.LocalDelete, documentId: pendingDocumentId ?? documentId! }); + const deleteId = pendingDocumentId ?? documentId; + if (deleteId === undefined) { + throw new Error("Unreachable: deleteId must be defined here"); + } + this.events.push({ + type: SyncEventType.LocalDelete, + documentId: deleteId + }); return; } @@ -137,30 +156,43 @@ export class SyncEventQueue { if (pendingDocumentId !== undefined) { this.updatePendingCreatePath(input.oldPath, path); } else { + if (record === undefined) { + throw new Error( + "Unreachable: record must be defined for non-pending update" + ); + } this.documents.delete(input.oldPath); - this.documents.set(path, record!); + this.documents.set(path, record); for (const e of this.events) { - // It already has a docId, so there can't be a pending create event for it - if (e.type === SyncEventType.LocalUpdate && e.documentId === documentId) { + // It already has a docId, so there can't be a pending create event for it + if ( + e.type === SyncEventType.LocalUpdate && + e.documentId === documentId + ) { e.path = path; } } await this.save(); - } - return + return; } - this.events.push({ type: SyncEventType.LocalUpdate, documentId: pendingDocumentId ?? documentId!, path, originalPath: path }); + const updateId = pendingDocumentId ?? documentId; + if (updateId === undefined) { + throw new Error("Unreachable: updateId must be defined here"); + } + this.events.push({ + type: SyncEventType.LocalUpdate, + documentId: updateId, + path, + originalPath: path + }); } - - public async next(): Promise { return this.events.shift(); } - /** * Call once a create has been acknowledged by the server. */ @@ -170,19 +202,21 @@ export class SyncEventQueue { ): Promise { removeFromArray(this.events, event); // in case the create event is still pending await this.setDocument(event.path, record); - event.resolvers?.resolve(record.documentId); + event.resolvers.resolve(record.documentId); } /** * Update the settled document map and persist the new document version. */ - public setDocument(path: RelativePath, record: DocumentRecord): Promise { + public async setDocument( + path: RelativePath, + record: DocumentRecord + ): Promise { this.documents.set(path, record); return this.save(); - } - public removeDocument(path: RelativePath): Promise { + public async removeDocument(path: RelativePath): Promise { this.documents.delete(path); return this.save(); } @@ -198,11 +232,7 @@ export class SyncEventQueue { return undefined; } - - - public getDocumentByDocumentIdOrFail( - target: DocumentId - ): DocumentWithPath { + public getDocumentByDocumentIdOrFail(target: DocumentId): DocumentWithPath { const result = this.getDocumentByDocumentId(target); if (!result) { throw new Error(`No document found with id ${target}`); @@ -210,10 +240,6 @@ export class SyncEventQueue { return result; } - - - - public async save(): Promise { return this.saveData({ documents: Array.from(this.documents.entries()).map( @@ -227,16 +253,16 @@ export class SyncEventQueue { } // todo: let's remove - public getSettledDocumentByPath(path: RelativePath): DocumentRecord | undefined { + public getSettledDocumentByPath( + path: RelativePath + ): DocumentRecord | undefined { return this.documents.get(path); } - public allSettledDocuments(): Map { return new Map(this.documents.entries()); } - public hasPendingEventsForPath(path: RelativePath): boolean { const record = this.documents.get(path); if (record === undefined) { @@ -252,7 +278,8 @@ export class SyncEventQueue { e.documentId === docId) || (e.type === SyncEventType.RemoteChange && // we care about the local path not the remote - this.getDocumentByDocumentId(e.remoteVersion.documentId)?.path === path) + this.getDocumentByDocumentId(e.remoteVersion.documentId) + ?.path === path) ); } @@ -266,11 +293,10 @@ export class SyncEventQueue { ); } - public async clearAllState(): Promise { this.clearPending(); this.documents.clear(); - this._lastSeenUpdateId.reset() + this._lastSeenUpdateId.reset(); await this.save(); } @@ -279,29 +305,6 @@ export class SyncEventQueue { this.events.length = 0; } - - private updatePendingCreatePath( - oldPath: RelativePath, - newPath: RelativePath - ): void { - const createEvent = this.findLatestCreateForPath(oldPath); - if (createEvent === undefined) return; - - const promise = createEvent.resolvers?.promise; - createEvent.path = newPath; - - if (promise !== undefined) { - for (const e of this.events) { - if ( - e.type === SyncEventType.LocalUpdate && - e.documentId === promise - ) { - e.path = newPath; - } - } - } - } - public findLatestCreateForPath( path: RelativePath ): Extract | undefined { @@ -314,18 +317,34 @@ export class SyncEventQueue { return undefined; } + private updatePendingCreatePath( + oldPath: RelativePath, + newPath: RelativePath + ): void { + const createEvent = this.findLatestCreateForPath(oldPath); + if (createEvent === undefined) return; + const { promise } = createEvent.resolvers; + createEvent.path = newPath; - - - private rejectAllPendingCreates(): void { - for (const event of this.events) { - if (event.type === SyncEventType.LocalCreate && event.resolvers !== undefined) { - event.resolvers.promise.catch(() => { /* suppressed — consumer may not be listening */ }); - event.resolvers.reject(new Error("Create was cancelled")); + for (const e of this.events) { + if ( + e.type === SyncEventType.LocalUpdate && + e.documentId === promise + ) { + e.path = newPath; } } } - + private rejectAllPendingCreates(): void { + for (const event of this.events) { + if (event.type === SyncEventType.LocalCreate) { + event.resolvers.promise.catch(() => { + /* suppressed — consumer may not be listening */ + }); + event.resolvers.reject(new Error("Create was cancelled")); + } + } + } } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index c0334dbf..298c35a4 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -4,12 +4,15 @@ import { type DocumentRecord, type SyncEvent, type RelativePath, - type VaultUpdateId, + type VaultUpdateId } from "./types"; import type { Logger } from "../tracing/logger"; import { hash } from "../utils/hash"; import type { Settings } from "../persistence/settings"; -import { MoveOnConflict, type FileOperations } from "../file-operations/file-operations"; +import { + MoveOnConflict, + type FileOperations +} from "../file-operations/file-operations"; import { scheduleOfflineChanges } from "./offline-change-detector"; import { SyncResetError } from "../errors/sync-reset-error"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; @@ -21,9 +24,7 @@ import type { SyncEventQueue } from "./sync-event-queue"; import type { SyncService } from "../services/sync-service"; import { FileNotFoundError } from "../errors/file-not-found-error"; import { HttpClientError } from "../errors/http-client-error"; -import type { - SyncHistory -} from "../tracing/sync-history"; +import type { SyncHistory } from "../tracing/sync-history"; import { SyncStatus, SyncType, @@ -79,7 +80,10 @@ export class Syncer { } public syncLocallyCreatedFile(relativePath: RelativePath): void { - void this.queue.enqueue({ type: SyncEventType.LocalCreate, path: relativePath }); + void this.queue.enqueue({ + type: SyncEventType.LocalCreate, + path: relativePath + }); this.ensureDraining(); } @@ -90,14 +94,18 @@ export class Syncer { oldPath?: RelativePath; relativePath: RelativePath; }): void { - void this.queue.enqueue({ type: SyncEventType.LocalUpdate, path: relativePath, oldPath }); + void this.queue.enqueue({ + type: SyncEventType.LocalUpdate, + path: relativePath, + oldPath + }); this.ensureDraining(); } public syncLocallyDeletedFile(relativePath: RelativePath): void { void this.queue.enqueue({ type: SyncEventType.LocalDelete, - path: relativePath, + path: relativePath }); this.ensureDraining(); } @@ -151,7 +159,6 @@ export class Syncer { } } - public reset(): void { this._isFirstSyncStarted = false; this.queue.clearPending(); @@ -162,20 +169,14 @@ export class Syncer { // fresh scan can only start once the prior one is done. const current = this.runningScheduleSyncForOfflineChanges; if (current !== undefined) { - current.finally(() => { - if ( - this.runningScheduleSyncForOfflineChanges === - current - ) { - this.runningScheduleSyncForOfflineChanges = - undefined; + void current.finally(() => { + if (this.runningScheduleSyncForOfflineChanges === current) { + this.runningScheduleSyncForOfflineChanges = undefined; } }); } } - - private sendHandshakeMessage(): void { const message: WebSocketClientMessage = { type: "handshake", @@ -186,8 +187,6 @@ export class Syncer { this.webSocketManager.sendHandshakeMessage(message); } - - private async internalScheduleSyncForOfflineChanges(): Promise { this.isScanning = true; try { @@ -195,10 +194,18 @@ export class Syncer { await this.drainPromise; } await scheduleOfflineChanges( - this.logger, this.operations, this.queue, - (path) => { this.syncLocallyCreatedFile(path); }, - (args) => { this.syncLocallyUpdatedFile(args); }, - (path) => { this.syncLocallyDeletedFile(path); }, + this.logger, + this.operations, + this.queue, + (path) => { + this.syncLocallyCreatedFile(path); + }, + (args) => { + this.syncLocallyUpdatedFile(args); + }, + (path) => { + this.syncLocallyDeletedFile(path); + } ); } finally { this.isScanning = false; @@ -207,9 +214,6 @@ export class Syncer { this.ensureDraining(); } - - - private ensureDraining(): void { if (this.drainPromise !== undefined) return; if (this.isScanning) return; @@ -218,7 +222,6 @@ export class Syncer { }); } - private async drain(): Promise { let event = await this.queue.next(); while (event !== undefined) { @@ -271,8 +274,10 @@ export class Syncer { `Skipping sync event '${event.type}' because the file no longer exists` ); if (event.type === SyncEventType.LocalCreate) { - event.resolvers?.promise.catch(() => { }); - event.resolvers?.reject(new Error("Create was cancelled")); + event.resolvers.promise.catch(() => { + /* suppressed */ + }); + event.resolvers.reject(new Error("Create was cancelled")); } return; } @@ -285,10 +290,10 @@ export class Syncer { // promise would otherwise hang forever, blocking any // queued Delete / SyncLocal that `await`s it. if (event.type === SyncEventType.LocalCreate) { - event.resolvers?.promise.catch(() => { + event.resolvers.promise.catch(() => { /* suppressed */ }); - event.resolvers?.reject( + event.resolvers.reject( new Error( `Create was cancelled — server rejected the request (${e.message})` ) @@ -300,10 +305,9 @@ export class Syncer { } } - private async skipIfOversized(event: SyncEvent): Promise { - let sizeInBytes: number; - let relativePath: RelativePath; + let sizeInBytes = 0; + let relativePath: RelativePath = ""; switch (event.type) { case SyncEventType.LocalDelete: @@ -316,7 +320,7 @@ export class Syncer { case SyncEventType.RemoteChange: if (event.remoteVersion.isDeleted) return false; sizeInBytes = event.remoteVersion.contentSize; - relativePath = event.remoteVersion.relativePath; + ({ relativePath } = event.remoteVersion); break; } @@ -329,8 +333,10 @@ export class Syncer { this.history.addHistoryEntry(oversizedEntry); if (event.type === SyncEventType.LocalCreate) { - event.resolvers?.promise.catch(() => { }); - event.resolvers?.reject(new Error("Create was cancelled")); + event.resolvers.promise.catch(() => { + /* suppressed */ + }); + event.resolvers.reject(new Error("Create was cancelled")); } return true; @@ -354,9 +360,6 @@ export class Syncer { } } - - - private async processCreate( event: Extract ): Promise { @@ -378,13 +381,13 @@ export class Syncer { createEvent: event }); - this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { type: SyncType.CREATE, relativePath: effectivePath }, - message: response.type === "MergingUpdate" - ? "Created file and merged with existing remote version" - : "Successfully created file on the server", + message: + response.type === "MergingUpdate" + ? "Created file and merged with existing remote version" + : "Successfully created file on the server", author: response.userId, timestamp: new Date(response.updatedDate) }); @@ -393,7 +396,7 @@ export class Syncer { private async processDelete( event: Extract ): Promise { - let documentId = await event.documentId; + const documentId = await event.documentId; const doc = this.queue.getDocumentByDocumentIdOrFail(documentId); const relativePath = doc.path; @@ -406,7 +409,6 @@ export class Syncer { await this.queue.removeDocument(doc.path); this.queue.lastSeenUpdateId = response.vaultUpdateId; - this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { @@ -421,16 +423,16 @@ export class Syncer { private async processLocalUpdate( event: Extract ): Promise { - let documentId = await event.documentId; + const documentId = await event.documentId; - const { path: diskPath, record } = this.queue.getDocumentByDocumentIdOrFail(documentId); + const { path: diskPath, record } = + this.queue.getDocumentByDocumentIdOrFail(documentId); const contentBytes = await this.operations.read(diskPath); const contentHash = await hash(contentBytes); const hashChanged = contentHash !== record.remoteHash; - const pathChanged = - record.remoteRelativePath !== event.originalPath; + const pathChanged = record.remoteRelativePath !== event.originalPath; if (!hashChanged && !pathChanged) { this.logger.debug( @@ -443,12 +445,10 @@ export class Syncer { record, relativePath: event.originalPath, contentBytes - } - ); + }); this.queue.lastSeenUpdateId = response.vaultUpdateId; - await this.handleMaybeMergingResponse({ path: diskPath, response, @@ -456,9 +456,7 @@ export class Syncer { originalContentBytes: contentBytes }); - - const isMerge = - "type" in response && response.type === "MergingUpdate"; + const isMerge = "type" in response && response.type === "MergingUpdate"; this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { @@ -489,12 +487,12 @@ export class Syncer { // response) createEvent?: Extract; }): Promise { - let record = { + const record = { documentId: response.documentId, parentVersionId: response.vaultUpdateId, remoteRelativePath: response.relativePath }; - let remoteHash: string; + let remoteHash = ""; if ("type" in response && response.type === "MergingUpdate") { const responseBytes = base64ToBytes(response.contentBase64); @@ -506,11 +504,7 @@ export class Syncer { remoteHash = await hash(responseBytes); - await this.updateCache( - response.vaultUpdateId, - responseBytes, - path - ); + await this.updateCache(response.vaultUpdateId, responseBytes, path); } else { // Fast-forward update: no merge needed remoteHash = contentHash; @@ -524,13 +518,16 @@ export class Syncer { if (createEvent === undefined) { // a http response will always be more up-to-date than any queued remote update - this.operations.move(path, response.relativePath, MoveOnConflict.EXISTING); + await this.operations.move( + path, + response.relativePath, + MoveOnConflict.EXISTING + ); await this.queue.setDocument(response.relativePath, { ...record, remoteHash }); - } else { // The response to a create must contain the path from the create request await this.queue.resolveCreate(createEvent, { @@ -542,7 +539,6 @@ export class Syncer { this.queue.lastSeenUpdateId = response.vaultUpdateId; } - private async processRemoteChange( event: Extract ): Promise { @@ -556,10 +552,16 @@ export class Syncer { // trying to delete a document we've already scheduled for deletion locally return; } - return this.processRemoteDelete(documentWithPath.path, remoteVersion); + return this.processRemoteDelete( + documentWithPath.path, + remoteVersion + ); } - if (documentWithPath?.record.parentVersionId ?? 0 >= remoteVersion.vaultUpdateId) { + if ( + (documentWithPath?.record.parentVersionId ?? 0) >= + remoteVersion.vaultUpdateId + ) { this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; this.logger.debug( `Document ${remoteVersion.relativePath} is already up-to-date or has newer local changes; skipping remote update` @@ -569,26 +571,36 @@ export class Syncer { if (documentWithPath !== undefined) { // must be the update to an existing doc - return this.processRemoteUpdate(documentWithPath.path, documentWithPath.record, remoteVersion); + return this.processRemoteUpdate( + documentWithPath.path, + documentWithPath.record, + remoteVersion + ); } - const pendingCreate = this.queue.findLatestCreateForPath(remoteVersion.relativePath); + const pendingCreate = this.queue.findLatestCreateForPath( + remoteVersion.relativePath + ); if (pendingCreate === undefined) { return this.processRemoteCreateForNewDocument(remoteVersion); } else { - return this.processRemoteCreateForPendingDocument(remoteVersion, pendingCreate); + return this.processRemoteCreateForPendingDocument( + remoteVersion, + pendingCreate + ); } } - - private async processRemoteDelete(path: RelativePath, remoteVersion: DocumentVersionWithoutContent): Promise { + private async processRemoteDelete( + path: RelativePath, + remoteVersion: DocumentVersionWithoutContent + ): Promise { await this.operations.delete(path); await this.queue.removeDocument(path); this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; - this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { @@ -602,22 +614,29 @@ export class Syncer { }); } - private async processRemoteUpdate(path: RelativePath, record: DocumentRecord, remoteVersion: DocumentVersionWithoutContent): Promise { - if ( - record.parentVersionId >= - remoteVersion.vaultUpdateId - ) { - this.logger.debug( - `Document ${path} is already up-to-date` - ); + private async processRemoteUpdate( + path: RelativePath, + record: DocumentRecord, + remoteVersion: DocumentVersionWithoutContent + ): Promise { + if (record.parentVersionId >= remoteVersion.vaultUpdateId) { + this.logger.debug(`Document ${path} is already up-to-date`); return; } - if (!this.queue.hasPendingLocalEventsForDocumentId(remoteVersion.documentId)) { + if ( + !this.queue.hasPendingLocalEventsForDocumentId( + remoteVersion.documentId + ) + ) { // no local changes const currentContent = await this.operations.read(path); - const remoteContent = await this.syncService.getDocumentVersionContent({ documentId: remoteVersion.documentId, vaultUpdateId: remoteVersion.vaultUpdateId }); - this.operations.write(path, currentContent, remoteContent); + const remoteContent = + await this.syncService.getDocumentVersionContent({ + documentId: remoteVersion.documentId, + vaultUpdateId: remoteVersion.vaultUpdateId + }); + await this.operations.write(path, currentContent, remoteContent); await this.updateCache( remoteVersion.vaultUpdateId, @@ -625,20 +644,26 @@ export class Syncer { path ); this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; + } // else we don't need to update the content, a subsequent local update will do that - } // else we don't need to update the content, a subsequent local update will do that - - this.syncRemotelyUpdatedFile({ // schedule it so that the lastSeenUpdateId remains consistent - document: - remoteVersion - }) - + void this.syncRemotelyUpdatedFile({ + // schedule it so that the lastSeenUpdateId remains consistent + document: remoteVersion + }); // wait for a local edit to do the actual updating here, so we can't even update the lastSeenUpdateId here - const conflictingDoc = this.queue.getSettledDocumentByPath(remoteVersion.relativePath); - const actualRelativePath = await this.operations.move(path, remoteVersion.relativePath, conflictingDoc?.parentVersionId ?? 0 < remoteVersion.vaultUpdateId ? MoveOnConflict.EXISTING : MoveOnConflict.NEW); + const conflictingDoc = this.queue.getSettledDocumentByPath( + remoteVersion.relativePath + ); + const actualRelativePath = await this.operations.move( + path, + remoteVersion.relativePath, + (conflictingDoc?.parentVersionId ?? 0) < remoteVersion.vaultUpdateId + ? MoveOnConflict.EXISTING + : MoveOnConflict.NEW + ); - this.queue.setDocument(actualRelativePath, { + await this.queue.setDocument(actualRelativePath, { ...record, remoteRelativePath: actualRelativePath }); @@ -651,22 +676,28 @@ export class Syncer { movedFrom: path }, // todo: eh - message: `File was renamed remotely from ${path} to ${actualRelativePath}`, + message: `File was renamed remotely from ${path} to ${actualRelativePath}` }); } - private async processRemoteCreateForNewDocument(remoteVersion: DocumentVersionWithoutContent): Promise { + private async processRemoteCreateForNewDocument( + remoteVersion: DocumentVersionWithoutContent + ): Promise { const remoteContent = await this.syncService.getDocumentVersionContent({ documentId: remoteVersion.documentId, vaultUpdateId: remoteVersion.vaultUpdateId }); - const conflictingDoc = this.queue.getSettledDocumentByPath(remoteVersion.relativePath); + const conflictingDoc = this.queue.getSettledDocumentByPath( + remoteVersion.relativePath + ); const actualPath = await this.operations.create( remoteVersion.relativePath, remoteContent, - conflictingDoc?.parentVersionId ?? 0 < remoteVersion.vaultUpdateId ? MoveOnConflict.EXISTING : MoveOnConflict.NEW + (conflictingDoc?.parentVersionId ?? 0) < remoteVersion.vaultUpdateId + ? MoveOnConflict.EXISTING + : MoveOnConflict.NEW ); await this.updateCache( @@ -703,7 +734,10 @@ export class Syncer { // We must avoid duplicating files. private async processRemoteCreateForPendingDocument( remoteVersion: DocumentVersionWithoutContent, - pendingCreateEvent: Extract + pendingCreateEvent: Extract< + SyncEvent, + { type: SyncEventType.LocalCreate } + > ): Promise { const remoteContent = await this.syncService.getDocumentVersionContent({ documentId: remoteVersion.documentId, @@ -712,7 +746,9 @@ export class Syncer { const remoteHash = await hash(remoteContent); const path = remoteVersion.relativePath; - const currentContent = await this.operations.read(pendingCreateEvent.path); + const currentContent = await this.operations.read( + pendingCreateEvent.path + ); await this.operations.write(path, currentContent, remoteContent); await this.updateCache( @@ -735,25 +771,21 @@ export class Syncer { type: SyncType.UPDATE, relativePath: path }, - message: - `Adopted remote create at ${path}`, + message: `Adopted remote create at ${path}`, author: remoteVersion.userId, timestamp: new Date(remoteVersion.updatedDate) }); - } - - - - - private async sendUpdate( - { record, relativePath, contentBytes }: { - record: DocumentRecord, - relativePath: RelativePath, - contentBytes: Uint8Array - } - ): Promise { + private async sendUpdate({ + record, + relativePath, + contentBytes + }: { + record: DocumentRecord; + relativePath: RelativePath; + contentBytes: Uint8Array; + }): Promise { const isText = !isBinary(contentBytes) && isFileTypeMergable( @@ -783,8 +815,6 @@ export class Syncer { }); } - - private async updateCache( updateId: VaultUpdateId, contentBytes: Uint8Array, diff --git a/frontend/sync-client/src/sync-operations/types.ts b/frontend/sync-client/src/sync-operations/types.ts index 9d2cedac..4cdac588 100644 --- a/frontend/sync-client/src/sync-operations/types.ts +++ b/frontend/sync-client/src/sync-operations/types.ts @@ -29,36 +29,41 @@ export enum SyncEventType { LocalCreate = "local-create", LocalUpdate = "local-update", // includes both content and path changes LocalDelete = "local-delete", - RemoteChange = "remote-change", // includes every type of create/update/delete coming from the server + RemoteChange = "remote-change" // includes every type of create/update/delete coming from the server } export type FileSyncEvent = | { type: SyncEventType.LocalCreate; path: RelativePath } | { - type: SyncEventType.LocalUpdate; path: RelativePath; oldPath?: RelativePath // oldPath is undefined for content changes - } + type: SyncEventType.LocalUpdate; + path: RelativePath; + oldPath?: RelativePath; // oldPath is undefined for content changes + } | { type: SyncEventType.LocalDelete; path: RelativePath } - | { type: SyncEventType.RemoteChange; remoteVersion: DocumentVersionWithoutContent }; + | { + type: SyncEventType.RemoteChange; + remoteVersion: DocumentVersionWithoutContent; + }; export type SyncEvent = | { - type: SyncEventType.LocalCreate; - path: RelativePath; // current path on disk - originalPath: RelativePath; // original path on disk when the event was queued - resolvers: PromiseWithResolvers - } + type: SyncEventType.LocalCreate; + path: RelativePath; // current path on disk + originalPath: RelativePath; // original path on disk when the event was queued + resolvers: PromiseWithResolvers; + } | { - type: SyncEventType.LocalUpdate; - documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed - path: RelativePath; // current path on disk - originalPath: RelativePath; // original path on disk when the event was queued - // no need to store the old path in case of a rename; the server will figure it out from the parent's path - } + type: SyncEventType.LocalUpdate; + documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed + path: RelativePath; // current path on disk + originalPath: RelativePath; // original path on disk when the event was queued + // no need to store the old path in case of a rename; the server will figure it out from the parent's path + } | { - type: SyncEventType.LocalDelete; - documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed - } + type: SyncEventType.LocalDelete; + documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed + } | { - type: SyncEventType.RemoteChange; - remoteVersion: DocumentVersionWithoutContent; - }; + type: SyncEventType.RemoteChange; + remoteVersion: DocumentVersionWithoutContent; + }; diff --git a/frontend/sync-client/src/utils/data-structures/locks.test.ts b/frontend/sync-client/src/utils/data-structures/locks.test.ts index fd8894b8..c98bda0b 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.test.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.test.ts @@ -265,7 +265,7 @@ describe("reset", () => { await sleep(1); const secondPromise = locks.withLock(testPath, async () => "second"); - void secondPromise.catch(() => { }); // eslint-disable-line @typescript-eslint/no-empty-function + void secondPromise.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function locks.reset(); @@ -286,7 +286,7 @@ describe("reset", () => { await sleep(1); const secondPromise = locks.withLock(testPath, async () => "second"); - void secondPromise.catch(() => { }); // eslint-disable-line @typescript-eslint/no-empty-function + void secondPromise.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function locks.reset(); @@ -312,7 +312,7 @@ describe("reset", () => { [testPath, testPath2], async () => "multi" ); - void multiKeyPromise.catch(() => { }); // eslint-disable-line @typescript-eslint/no-empty-function + void multiKeyPromise.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function // Wait for the multi-key operation to acquire testPath and start waiting on testPath2 await sleep(10); diff --git a/frontend/sync-client/src/utils/data-structures/locks.ts b/frontend/sync-client/src/utils/data-structures/locks.ts index 4e512869..99c33075 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.ts @@ -8,7 +8,7 @@ import type { Logger } from "../../tracing/logger"; * @template T The type of the key used for locking */ /** Waiter entry with callbacks */ -interface WaiterEntry { +interface WaiterEntry { resolve: () => unknown; reject: (err: unknown) => unknown; } @@ -18,9 +18,12 @@ export class Locks { private readonly locked = new Set(); /** Queue of waiters for each key */ - private readonly waiters = new Map[]>(); + private readonly waiters = new Map(); - public constructor(private readonly name: string, private readonly logger?: Logger) { } + public constructor( + private readonly name: string, + private readonly logger?: Logger + ) {} /** * Executes a function while holding exclusive locks on one or more keys. @@ -134,7 +137,7 @@ export class Locks { waiting.push({ resolve, - reject, + reject }); }); } diff --git a/frontend/sync-client/src/utils/data-structures/min-covered.ts b/frontend/sync-client/src/utils/data-structures/min-covered.ts index 720e20a3..f92ef26c 100644 --- a/frontend/sync-client/src/utils/data-structures/min-covered.ts +++ b/frontend/sync-client/src/utils/data-structures/min-covered.ts @@ -16,7 +16,7 @@ export class MinCovered { private seenValues: number[] = []; - public constructor(private minValue: number) { } + public constructor(private minValue: number) {} public get min(): number { return this.minValue; diff --git a/frontend/sync-client/src/utils/debugging/log-to-console.ts b/frontend/sync-client/src/utils/debugging/log-to-console.ts index f38335fe..def71400 100644 --- a/frontend/sync-client/src/utils/debugging/log-to-console.ts +++ b/frontend/sync-client/src/utils/debugging/log-to-console.ts @@ -16,12 +16,12 @@ export function logToConsole( ): void { logger.onLogEmitted.add((logLine: LogLine) => { const timestamp = logLine.timestamp.toISOString(); - const {message} = logLine; + const { message } = logLine; let color = ""; let reset = ""; if (useColors) { - reset = COLORS.reset; + ({ reset } = COLORS); switch (logLine.level) { case LogLevel.ERROR: color = COLORS.red; diff --git a/frontend/sync-client/src/utils/find-matching-file.ts b/frontend/sync-client/src/utils/find-matching-file.ts index 1b8df384..f5d65b39 100644 --- a/frontend/sync-client/src/utils/find-matching-file.ts +++ b/frontend/sync-client/src/utils/find-matching-file.ts @@ -1,4 +1,8 @@ -import type { DocumentRecord, DocumentWithPath, RelativePath } from "../sync-operations/types"; +import type { + DocumentRecord, + DocumentWithPath, + RelativePath +} from "../sync-operations/types"; import { EMPTY_HASH } from "./hash"; // TODO: make this smarter so that offline files can be renamed & edited at the same time @@ -6,7 +10,7 @@ export async function findMatchingFile( contentHash: string, candidates: { path: RelativePath; record: DocumentRecord }[] ): Promise { - if (contentHash === await EMPTY_HASH) { + if (contentHash === (await EMPTY_HASH)) { return undefined; } diff --git a/frontend/sync-client/src/utils/hash.ts b/frontend/sync-client/src/utils/hash.ts index 933929c5..dbda085b 100644 --- a/frontend/sync-client/src/utils/hash.ts +++ b/frontend/sync-client/src/utils/hash.ts @@ -1,8 +1,8 @@ export async function hash(content: Uint8Array): Promise { - const digest = await crypto.subtle.digest( - "SHA-256", - content as Uint8Array - ); + // Copy into a fresh ArrayBuffer-backed Uint8Array so the buffer type + // matches `BufferSource`/`Uint8Array` expected by digest. + const owned = new Uint8Array(content); + const digest = await crypto.subtle.digest("SHA-256", owned); const bytes = new Uint8Array(digest); return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } diff --git a/frontend/sync-client/src/utils/rate-limit.ts b/frontend/sync-client/src/utils/rate-limit.ts index 54373f50..2721de16 100644 --- a/frontend/sync-client/src/utils/rate-limit.ts +++ b/frontend/sync-client/src/utils/rate-limit.ts @@ -44,14 +44,16 @@ export function rateLimit< newArgs = undefined; } - const { promise, resolve } = Promise.withResolvers(); + const { promise, resolve } = Promise.withResolvers(); running = promise; sleep( typeof minIntervalMs === "function" ? minIntervalMs() : minIntervalMs ) - .then(resolve) + .then(() => { + resolve(undefined); + }) .catch(() => { // sleep cannot fail }); diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index 1422ac23..00acc600 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ import { choose } from "../utils/choose"; import { v4 as uuidv4 } from "uuid"; import { assert } from "../utils/assert"; @@ -109,7 +110,6 @@ export class MockAgent extends MockClient { ); } - public async act(): Promise { const options: (() => Promise)[] = [ this.createFileAction.bind(this), @@ -125,7 +125,6 @@ export class MockAgent extends MockClient { options.push(this.enableSyncAction.bind(this)); } - options.push( this.renameFileAction.bind(this), this.updateFileAction.bind(this), @@ -136,7 +135,6 @@ export class MockAgent extends MockClient { options.push(this.deleteFileAction.bind(this)); } - if (Math.random() < 0.015 && this.doResets) { // we can't just queue this up as once it's destroyed, no more method calls can go to SyncClient await this.resetClient(); @@ -164,7 +162,7 @@ export class MockAgent extends MockClient { // pending operations. if ( error instanceof Error && - error.message?.includes("SyncClient destroyed") + error.message.includes("SyncClient destroyed") ) { this.client.logger.info( `Action interrupted by destroy: ${error}` @@ -262,17 +260,18 @@ export class MockAgent extends MockClient { "Local files: " + Array.from(this.files.keys()).join(", ") ); otherAgent.client.logger.info( - "Other agent's data: " + JSON.stringify(otherAgent.data, null, 2) + "Other agent's data: " + + JSON.stringify(otherAgent.data, null, 2) ); otherAgent.client.logger.info( - "Other agent's files: " + Array.from(otherAgent.files.keys()).join(", ") + "Other agent's files: " + + Array.from(otherAgent.files.keys()).join(", ") ); throw e; } } - public assertAllContentIsPresentOnce(): void { if (this.useSlowFileEvents) { this.client.logger.info( @@ -349,7 +348,6 @@ export class MockAgent extends MockClient { } } - private async resetClient(): Promise { this.client.logger.info(`Resetting client ${this.name}`); await this.client.destroy(); @@ -372,8 +370,7 @@ export class MockAgent extends MockClient { `Decided to create file ${file} with content ${content}` ); - - return this.write(file, new TextEncoder().encode(` ${content} `),); + return this.write(file, new TextEncoder().encode(` ${content} `)); } // Binary file creation — exercises the putBinary server path (not in mergeable_file_extensions) @@ -393,7 +390,7 @@ export class MockAgent extends MockClient { `Decided to create binary file ${file}: ${uuid}` ); - return this.write(file, bytes,); + return this.write(file, bytes); } private async disableSyncAction(): Promise { @@ -433,9 +430,8 @@ export class MockAgent extends MockClient { // assertion to fail when the sync engine replaces binary content // at a mergeable path). const ext = file.substring(file.lastIndexOf(".")); - const newName = ext === ".bin" - ? this.getBinaryFileName() - : this.getFileName(); + const newName = + ext === ".bin" ? this.getBinaryFileName() : this.getFileName(); if ( (!this.lastSyncEnabledState && @@ -479,14 +475,10 @@ export class MockAgent extends MockClient { `Decided to update file ${file} with ${content}` ); this.doNotTouchWhileOffline.push(file); - await this.atomicUpdateText( - file, - (old) => ({ - text: old.text + ` ${content} `, - cursors: [] - }) - ); - + await this.atomicUpdateText(file, (old) => ({ + text: old.text + ` ${content} `, + cursors: [] + })); } private async updateBinaryFileAction(): Promise { @@ -506,12 +498,10 @@ export class MockAgent extends MockClient { return; } - const { uuid, bytes } = this.getBinaryContent(); + const { uuid: _uuid, bytes } = this.getBinaryContent(); // Remove the old UUID since binary updates are last-write-wins this.removeBinaryUuid(file); - this.client.logger.info( - `Decided to update binary file ${file}` - ); + this.client.logger.info(`Decided to update binary file ${file}`); this.doNotTouchWhileOffline.push(file); await this.write(file, bytes); } @@ -531,7 +521,6 @@ export class MockAgent extends MockClient { `Deleting file: ${file} with:\n content '${new TextDecoder().decode(this.files.get(file))}'` ); await this.delete(file); - } private getContent(): string { @@ -546,8 +535,7 @@ export class MockAgent extends MockClient { const content = new TextDecoder().decode(existing); if (!content.startsWith("BINARY:")) return; const uuid = content.slice("BINARY:".length); - const idx = this.writtenBinaryContents.indexOf(uuid); - if (idx !== -1) this.writtenBinaryContents.splice(idx, 1); + utils.removeFromArray(this.writtenBinaryContents, uuid); } private getBinaryContent(): { uuid: string; bytes: Uint8Array } { diff --git a/frontend/test-client/src/agent/mock-client.ts b/frontend/test-client/src/agent/mock-client.ts index 5d816aa4..84da4167 100644 --- a/frontend/test-client/src/agent/mock-client.ts +++ b/frontend/test-client/src/agent/mock-client.ts @@ -40,7 +40,6 @@ export class MockClient extends debugging.InMemoryFileSystem { await this.client.start(); } - public override async write( path: RelativePath, content: Uint8Array @@ -50,14 +49,14 @@ export class MockClient extends debugging.InMemoryFileSystem { this.files.set(path, content); if (isNew) { - this.executeFileOperation(async () => { this.client.syncLocallyCreatedFile(path); } - ); + this.executeFileOperation(async () => { + this.client.syncLocallyCreatedFile(path); + }); } else { - this.executeFileOperation( - async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); }, - ); + this.executeFileOperation(async () => { + this.client.syncLocallyUpdatedFile({ relativePath: path }); + }); } - } public override async atomicUpdateText( @@ -73,20 +72,18 @@ export class MockClient extends debugging.InMemoryFileSystem { const newContentUint8Array = new TextEncoder().encode(newContent); this.files.set(path, newContentUint8Array); - this.executeFileOperation( - async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); }, - ); + this.executeFileOperation(async () => { + this.client.syncLocallyUpdatedFile({ relativePath: path }); + }); return newContent; } - - public override async delete(path: RelativePath): Promise { this.files.delete(path); - this.executeFileOperation( - async () => { this.client.syncLocallyDeletedFile(path); }, - ); + this.executeFileOperation(async () => { + this.client.syncLocallyDeletedFile(path); + }); } public override async rename( @@ -101,17 +98,15 @@ export class MockClient extends debugging.InMemoryFileSystem { if (oldPath !== newPath) { this.files.delete(oldPath); } - this.executeFileOperation( - async () => { this.client.syncLocallyUpdatedFile({ + this.executeFileOperation(async () => { + this.client.syncLocallyUpdatedFile({ oldPath, relativePath: newPath - }); }, - ); + }); + }); } - protected executeFileOperation( - callback: () => unknown, - ): void { + protected executeFileOperation(callback: () => unknown): void { if (this.useSlowFileEvents) { // we aren't the best client and it takes some time to notice changes setTimeout(callback, Math.random() * 100); diff --git a/frontend/test-client/src/cli.ts b/frontend/test-client/src/cli.ts index 0fcd975b..ece94cc3 100644 --- a/frontend/test-client/src/cli.ts +++ b/frontend/test-client/src/cli.ts @@ -119,9 +119,6 @@ async function runTest({ await sleep(2000); } - - - for (const client of clients) { try { logger.info(`Destroying ${client.name}`); @@ -254,11 +251,7 @@ process.on("uncaughtException", (error) => { }); process.on("unhandledRejection", (error, _promise) => { - if ( - error instanceof Error && - ( - error.name === "SyncResetError") - ) { + if (error instanceof Error && error.name === "SyncResetError") { return; } diff --git a/frontend/test-client/src/utils/test-error-tracker.ts b/frontend/test-client/src/utils/test-error-tracker.ts index cf40a76c..4620b1e3 100644 --- a/frontend/test-client/src/utils/test-error-tracker.ts +++ b/frontend/test-client/src/utils/test-error-tracker.ts @@ -12,9 +12,7 @@ export class TestErrorTracker { public checkAndThrow(): void { if (this.firstError !== null) { const { agentName, message } = this.firstError; - throw new Error( - `ERROR-level log from ${agentName}: ${message}` - ); + throw new Error(`ERROR-level log from ${agentName}: ${message}`); } } diff --git a/package-lock.json b/package-lock.json index 9e0474fd..a669e690 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { - "name": "vault-link", - "lockfileVersion": 3, - "requires": true, - "packages": {} + "name": "vault-link", + "lockfileVersion": 3, + "requires": true, + "packages": {} } diff --git a/sync-server/config-e2e.yml b/sync-server/config-e2e.yml index d0f76446..242f69ef 100644 --- a/sync-server/config-e2e.yml +++ b/sync-server/config-e2e.yml @@ -10,24 +10,24 @@ server: broadcast_channel_capacity: 1024 response_timeout: 30m mergeable_file_extensions: - - md - - txt + - md + - txt users: user_configs: - - name: admin - token: test-token-change-me - vault_access: - type: allow_access_to_all - - name: other-admin - token: test-token-change-me2 - vault_access: - type: allow_access_to_all - - name: test - token: other-test-token - vault_access: - type: allow_list - allowed: - - default + - name: admin + token: test-token-change-me + vault_access: + type: allow_access_to_all + - name: other-admin + token: test-token-change-me2 + vault_access: + type: allow_access_to_all + - name: test + token: other-test-token + vault_access: + type: allow_list + allowed: + - default logging: log_directory: logs log_rotation: 7days From bff3f5a5e98e5a4923811498044cea78ea662665 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 19:13:26 +0100 Subject: [PATCH 20/52] ai fixes --- frontend/deterministic-tests/README.md | 2 +- .../src/services/websocket-manager.test.ts | 61 +++++++++++++ .../src/services/websocket-manager.ts | 14 ++- frontend/sync-client/src/sync-client.ts | 59 ++++++++----- .../offline-change-detector.ts | 4 +- .../src/sync-operations/sync-event-queue.ts | 13 +-- .../sync-client/src/sync-operations/syncer.ts | 85 ++++++++++--------- frontend/sync-client/src/utils/hash.ts | 8 +- 8 files changed, 167 insertions(+), 79 deletions(-) diff --git a/frontend/deterministic-tests/README.md b/frontend/deterministic-tests/README.md index 0fe053f0..fb60a919 100644 --- a/frontend/deterministic-tests/README.md +++ b/frontend/deterministic-tests/README.md @@ -42,7 +42,7 @@ Clients always start with syncing disabled. ```sh # Build server first -cd sync-server && cargo build --release +cd sync-server && cargo build --release && cd - # Run all tests cd frontend && npm run test -w deterministic-tests diff --git a/frontend/sync-client/src/services/websocket-manager.test.ts b/frontend/sync-client/src/services/websocket-manager.test.ts index 3b61b5a1..7f72ed4a 100644 --- a/frontend/sync-client/src/services/websocket-manager.test.ts +++ b/frontend/sync-client/src/services/websocket-manager.test.ts @@ -246,6 +246,67 @@ describe("WebSocketManager", () => { await manager.stop(); }); + it("handles concurrent stop() calls without stranding either caller", async () => { + // Real WebSocket.close() doesn't fire onclose synchronously, and the + // socket stays reachable across the close handshake. Model that + // here so the manager's `while (isWebSocketConnected)` loop is + // actually awaiting when the second stop() races in. Static OPEN + // is required because the manager compares readyState against + // `factory.OPEN`. + class AsyncCloseWebSocket extends MockWebSocket { + public static readonly OPEN = WebSocket.OPEN; + + public override close(code?: number, reason?: string): void { + if ( + this.readyState === WebSocket.CLOSED || + (this as { _closing?: boolean })._closing === true + ) { + return; + } + (this as { _closing?: boolean })._closing = true; + setTimeout(() => { + this.readyState = WebSocket.CLOSED; + this.onclose?.( + new MockCloseEvent("close", { + code: code ?? 1000, + reason: reason ?? "" + }) + ); + }, 5); + } + } + + const manager = new WebSocketManager( + mockLogger, + mockSettings, + AsyncCloseWebSocket as unknown as typeof WebSocket + ); + + manager.start(); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const start = Date.now(); + // Two concurrent stops mimic destroy() racing onSettingsChange. + await Promise.all([manager.stop(), manager.stop()]); + const elapsed = Date.now() - start; + + // Both should resolve via the normal close path; if the second call + // had clobbered the first's resolver, the first would have been + // stranded until the 10s disconnect timeout. + assert.ok( + elapsed < 1000, + `concurrent stop() took ${elapsed}ms — expected fast resolution` + ); + const errorCalls = ( + mockLogger.error as unknown as { calls: unknown[] } + ).calls; + assert.strictEqual( + errorCalls.length, + 0, + "no timeout-recovery error should be logged" + ); + }); + it("tracks message handling promises", async () => { const manager = new WebSocketManager( mockLogger, diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index 5279d0e6..4d26d404 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -28,6 +28,7 @@ export class WebSocketManager { private isStopped = true; private resolveDisconnectingPromise: null | (() => unknown) = null; + private stopPromise: Promise | null = null; private reconnectTimeoutId: ReturnType | undefined; private connectionTimeoutId: ReturnType | undefined; @@ -58,6 +59,17 @@ export class WebSocketManager { } public async stop(): Promise { + // Concurrent callers (e.g. destroy() and onSettingsChange) must share + // the same disconnect; otherwise the second call would overwrite + // resolveDisconnectingPromise and strand the first caller's await + // until the timeout rejects. + this.stopPromise ??= this.performStop().finally(() => { + this.stopPromise = null; + }); + await this.stopPromise; + } + + private async performStop(): Promise { const { promise, resolve } = Promise.withResolvers(); this.resolveDisconnectingPromise = (): void => { resolve(undefined); @@ -98,7 +110,7 @@ export class WebSocketManager { `Error while waiting for WebSocket to close: ${String(error)}` ); // Force cleanup even if close didn't work - this.resolveDisconnectingPromise(); + this.resolveDisconnectingPromise?.(); this.resolveDisconnectingPromise = null; } finally { // Clear timeout to prevent unhandled rejection diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 9c919354..9171b998 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -26,6 +26,7 @@ import { setUpTelemetry } from "./utils/set-up-telemetry"; import { DIFF_CACHE_SIZE_MB } from "./consts"; import { ServerConfig } from "./services/server-config"; import type { EventListeners } from "./utils/data-structures/event-listeners"; +import { Lock } from "./utils/data-structures/locks"; export class SyncClient { private hasFinishedOfflineSync = false; @@ -34,6 +35,7 @@ export class SyncClient { private unloadTelemetry?: () => void; private isDestroying = false; private readonly eventUnsubscribers: (() => void)[] = []; + private readonly settingsChangeLock = new Lock("SyncClient.onSettingsChange"); private constructor( public readonly logger: Logger, @@ -490,32 +492,45 @@ export class SyncClient { ): Promise { this.checkIfDestroyed("onSettingsChange"); - if ( - newSettings.vaultName !== oldSettings.vaultName || - newSettings.remoteUri !== oldSettings.remoteUri - ) { - await this.reset(); - } - - if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) { - if (newSettings.isSyncEnabled) { - await this.startSyncing(); - } else { - await this.pause(); + // Serialize listener invocations so back-to-back settings updates + // can't run reset()/pause()/startSyncing() concurrently. + await this.settingsChangeLock.withLock(async () => { + // The lock is FIFO, so by the time we run the client may have + // been destroyed in a queued invocation ahead of us. + if (this.hasBeenDestroyed) { + return; } - } - if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) { - this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024); - } + const connectionChanged = + newSettings.vaultName !== oldSettings.vaultName || + newSettings.remoteUri !== oldSettings.remoteUri; - if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) { - if (newSettings.enableTelemetry) { - this.unloadTelemetry = setUpTelemetry(); - } else { - this.unloadTelemetry?.(); + if (connectionChanged) { + // reset() pauses, clears state, then starts iff isSyncEnabled + // — so any concurrent isSyncEnabled change is already applied. + await this.reset(); + } else if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) { + if (newSettings.isSyncEnabled) { + await this.startSyncing(); + } else { + await this.pause(); + } } - } + + if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) { + this.contentCache.resize( + newSettings.diffCacheSizeMB * 1024 * 1024 + ); + } + + if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) { + if (newSettings.enableTelemetry) { + this.unloadTelemetry = setUpTelemetry(); + } else { + this.unloadTelemetry?.(); + } + } + }); } private checkIfDestroyed(origin: string): void { diff --git a/frontend/sync-client/src/sync-operations/offline-change-detector.ts b/frontend/sync-client/src/sync-operations/offline-change-detector.ts index 1c07ef42..a5e753a1 100644 --- a/frontend/sync-client/src/sync-operations/offline-change-detector.ts +++ b/frontend/sync-client/src/sync-operations/offline-change-detector.ts @@ -45,6 +45,7 @@ export async function scheduleOfflineChanges( } } + const renamedPaths = new Set(); for (const path of locallyPossibleCreatedFiles) { const content = await operations.read(path); const contentHash = await hash(content); @@ -62,11 +63,12 @@ export async function scheduleOfflineChanges( relativePath: path }); removeFromArray(locallyPossiblyDeletedFiles, matchingDeletedFile); - removeFromArray(locallyPossibleCreatedFiles, path); + renamedPaths.add(path); } } for (const path of locallyPossibleCreatedFiles) { + if (renamedPaths.has(path)) continue; logger.debug( `File ${path} was created while offline, scheduling sync to create it` ); diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index 66ddf7eb..7ad34fdc 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -141,13 +141,9 @@ export class SyncEventQueue { } if (input.type === SyncEventType.LocalDelete) { - const deleteId = pendingDocumentId ?? documentId; - if (deleteId === undefined) { - throw new Error("Unreachable: deleteId must be defined here"); - } this.events.push({ type: SyncEventType.LocalDelete, - documentId: deleteId + documentId: (pendingDocumentId ?? documentId)! }); return; } @@ -174,16 +170,11 @@ export class SyncEventQueue { } await this.save(); } - return; } - const updateId = pendingDocumentId ?? documentId; - if (updateId === undefined) { - throw new Error("Unreachable: updateId must be defined here"); - } this.events.push({ type: SyncEventType.LocalUpdate, - documentId: updateId, + documentId: (pendingDocumentId ?? documentId)!, path, originalPath: path }); diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 298c35a4..871a61b6 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -162,11 +162,6 @@ export class Syncer { public reset(): void { this._isFirstSyncStarted = false; this.queue.clearPending(); - // Don't null the reference synchronously — if the scan is - // still in flight, the next reconnect would spawn a second - // concurrent scan racing on the same queue. Defer the - // clear until the in-flight task actually resolves, so a - // fresh scan can only start once the prior one is done. const current = this.runningScheduleSyncForOfflineChanges; if (current !== undefined) { void current.finally(() => { @@ -619,11 +614,17 @@ export class Syncer { record: DocumentRecord, remoteVersion: DocumentVersionWithoutContent ): Promise { - if (record.parentVersionId >= remoteVersion.vaultUpdateId) { - this.logger.debug(`Document ${path} is already up-to-date`); - return; - } - + // wait for a local edit to do the actual updating here, so we can't even update the lastSeenUpdateId here + const conflictingDoc = this.queue.getSettledDocumentByPath( + remoteVersion.relativePath + ); + const actualPath = await this.operations.move( + path, + remoteVersion.relativePath, + (conflictingDoc?.parentVersionId ?? 0) < remoteVersion.vaultUpdateId + ? MoveOnConflict.EXISTING + : MoveOnConflict.NEW + ); if ( !this.queue.hasPendingLocalEventsForDocumentId( remoteVersion.documentId @@ -645,39 +646,45 @@ export class Syncer { ); this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; } // else we don't need to update the content, a subsequent local update will do that + else { + void this.syncRemotelyUpdatedFile({ + // schedule it so that the lastSeenUpdateId remains consistent + document: remoteVersion + }); - void this.syncRemotelyUpdatedFile({ - // schedule it so that the lastSeenUpdateId remains consistent - document: remoteVersion - }); - // wait for a local edit to do the actual updating here, so we can't even update the lastSeenUpdateId here - const conflictingDoc = this.queue.getSettledDocumentByPath( - remoteVersion.relativePath - ); - const actualRelativePath = await this.operations.move( - path, - remoteVersion.relativePath, - (conflictingDoc?.parentVersionId ?? 0) < remoteVersion.vaultUpdateId - ? MoveOnConflict.EXISTING - : MoveOnConflict.NEW - ); - await this.queue.setDocument(actualRelativePath, { - ...record, - remoteRelativePath: actualRelativePath - }); + await this.queue.setDocument(actualPath, { + ...record, + remoteRelativePath: actualPath + }); + } - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.MOVE, - relativePath: actualRelativePath, - movedFrom: path - }, - // todo: eh - message: `File was renamed remotely from ${path} to ${actualRelativePath}` - }); + + if (actualPath !== path) { + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: { + type: SyncType.MOVE, + relativePath: actualPath, + movedFrom: path + }, + message: `File was renamed remotely from ${path} to ${actualPath}`, + author: remoteVersion.userId, + timestamp: new Date(remoteVersion.updatedDate) + }); + } else { + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: { + type: SyncType.UPDATE, + relativePath: actualPath + }, + message: "Successfully applied remote update", + author: remoteVersion.userId, + timestamp: new Date(remoteVersion.updatedDate) + }); + } } private async processRemoteCreateForNewDocument( diff --git a/frontend/sync-client/src/utils/hash.ts b/frontend/sync-client/src/utils/hash.ts index dbda085b..933929c5 100644 --- a/frontend/sync-client/src/utils/hash.ts +++ b/frontend/sync-client/src/utils/hash.ts @@ -1,8 +1,8 @@ export async function hash(content: Uint8Array): Promise { - // Copy into a fresh ArrayBuffer-backed Uint8Array so the buffer type - // matches `BufferSource`/`Uint8Array` expected by digest. - const owned = new Uint8Array(content); - const digest = await crypto.subtle.digest("SHA-256", owned); + const digest = await crypto.subtle.digest( + "SHA-256", + content as Uint8Array + ); const bytes = new Uint8Array(digest); return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } From 8ce33541a3ed0c274666dcbfeac374f365fb786f Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 20:29:38 +0100 Subject: [PATCH 21/52] more ai changes --- frontend/deterministic-tests/README.md | 2 +- .../src/file-operations/file-operations.ts | 129 ++++++++++-------- .../sync-client/src/services/sync-service.ts | 2 +- .../src/services/websocket-manager.ts | 19 +-- frontend/sync-client/src/sync-client.ts | 15 +- .../src/sync-operations/cursor-tracker.ts | 104 +++++++------- .../sync-client/src/sync-operations/syncer.ts | 41 +++--- .../utils/data-structures/event-listeners.ts | 28 ++-- .../utils/data-structures/min-covered.test.ts | 22 ++- .../src/utils/data-structures/min-covered.ts | 8 +- frontend/sync-client/src/utils/rate-limit.ts | 23 ++-- 11 files changed, 208 insertions(+), 185 deletions(-) diff --git a/frontend/deterministic-tests/README.md b/frontend/deterministic-tests/README.md index fb60a919..c422406d 100644 --- a/frontend/deterministic-tests/README.md +++ b/frontend/deterministic-tests/README.md @@ -45,7 +45,7 @@ Clients always start with syncing disabled. cd sync-server && cargo build --release && cd - # Run all tests -cd frontend && npm run test -w deterministic-tests +cd frontend && npm run build -w sync-client && npm run test -w deterministic-tests # Filter by name npm run test -w deterministic-tests -- --filter=rename diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 29e9f0b6..f136afc0 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -8,6 +8,7 @@ import { isFileTypeMergable } from "../utils/is-file-type-mergable"; import { isBinary } from "../utils/is-binary"; import { buildConflictFileName } from "../sync-operations/conflict-path"; import type { ServerConfig } from "../services/server-config"; +import { FileNotFoundError } from "../errors/file-not-found-error"; export enum MoveOnConflict { EXISTING = "EXISTING", @@ -95,67 +96,83 @@ export class FileOperations { return; } - if ( - !isFileTypeMergable( - path, - (await this.serverConfig.getConfig()).mergeableFileExtensions - ) || - isBinary(expectedContent) || - isBinary(newContent) - ) { - this.logger.debug( - `The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it` - ); - await this.fs.write( - path, - // `newContent` might not be binary so we still have to ensure the line endings are correct - this.toNativeLineEndings(newContent) - ); - return; - } - - let expectedText = ""; - let newText = ""; + // The exists() check above is racy: between it returning true and + // any of the writes below running, the file can be deleted. The + // safe wrapper around `atomicUpdateText` raises FileNotFoundError + // in that window — treat it the same as the upfront-missing case + // (skip silently) so callers see one consistent outcome regardless + // of when the deletion happened to occur. try { - expectedText = new TextDecoder("utf-8", { fatal: true }).decode( - expectedContent - ); // this comes from a previous read which must only have \n line endings - newText = new TextDecoder("utf-8", { fatal: true }).decode( - newContent - ); // this comes from the server which stores text with \n line endings - } catch (decodeError) { - this.logger.warn( - `3-way merge aborted for ${path}: one of expected/new is not valid UTF-8 (${decodeError}); falling back to overwrite` - ); - await this.fs.write(path, this.toNativeLineEndings(newContent)); - return; - } - - await this.fs.atomicUpdateText( - path, - ({ text, cursors }: TextWithCursors): TextWithCursors => { + if ( + !isFileTypeMergable( + path, + (await this.serverConfig.getConfig()).mergeableFileExtensions + ) || + isBinary(expectedContent) || + isBinary(newContent) + ) { this.logger.debug( - `Performing a 3-way merge for ${path} with the expected content` + `The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it` ); - - text = text.replaceAll(this.nativeLineEndings, "\n"); - const merged = reconcile( - expectedText, - { text, cursors }, - newText + await this.fs.write( + path, + // `newContent` might not be binary so we still have to ensure the line endings are correct + this.toNativeLineEndings(newContent) ); - - const resultText = merged.text.replaceAll( - "\n", - this.nativeLineEndings - ); - - return { - text: resultText, - cursors: merged.cursors - }; + return; } - ); + + let expectedText = ""; + let newText = ""; + try { + expectedText = new TextDecoder("utf-8", { fatal: true }).decode( + expectedContent + ); // this comes from a previous read which must only have \n line endings + newText = new TextDecoder("utf-8", { fatal: true }).decode( + newContent + ); // this comes from the server which stores text with \n line endings + } catch (decodeError) { + this.logger.warn( + `3-way merge aborted for ${path}: one of expected/new is not valid UTF-8 (${decodeError}); falling back to overwrite` + ); + await this.fs.write(path, this.toNativeLineEndings(newContent)); + return; + } + + await this.fs.atomicUpdateText( + path, + ({ text, cursors }: TextWithCursors): TextWithCursors => { + this.logger.debug( + `Performing a 3-way merge for ${path} with the expected content` + ); + + text = text.replaceAll(this.nativeLineEndings, "\n"); + const merged = reconcile( + expectedText, + { text, cursors }, + newText + ); + + const resultText = merged.text.replaceAll( + "\n", + this.nativeLineEndings + ); + + return { + text: resultText, + cursors: merged.cursors + }; + } + ); + } catch (e) { + if (e instanceof FileNotFoundError) { + this.logger.debug( + `File ${path} disappeared during write; not recreating` + ); + return; + } + throw e; + } } public async delete(path: RelativePath): Promise { diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index 65726d73..228cc2f2 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -342,7 +342,7 @@ export class SyncService { const url = new URL(this.getUrl("/documents")); if (since !== undefined) { - url.searchParams.append("since", since.toString()); + url.searchParams.append("since_update_id", since.toString()); } const response = await this.client(url.toString(), { headers: this.getDefaultHeaders() diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index 4d26d404..3b0d4d44 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -204,19 +204,22 @@ export class WebSocketManager { this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`); - this.webSocket = new this.webSocketFactoryImplementation(wsUri); + const ws = new this.webSocketFactoryImplementation(wsUri); + this.webSocket = ws; - // Set connection timeout to handle cases where server is down and the WebSocket connection won't open + // Set connection timeout to handle cases where server is down and the WebSocket connection won't open. + // The callback closes the *captured* `ws` rather than `this.webSocket` so a delayed timeout cannot + // accidentally close a freshly-constructed replacement socket. (Closing the already-closed `ws` is a no-op.) this.connectionTimeoutId = setTimeout(() => { this.connectionTimeoutId = undefined; this.logger.warn( `WebSocket connection timeout after ${WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS} seconds` ); // Force close to trigger onclose handler which will schedule reconnection - this.webSocket?.close(1000, "Connection timeout"); + ws.close(1000, "Connection timeout"); }, WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS * 1000); - this.webSocket.onopen = (): void => { + ws.onopen = (): void => { if (this.connectionTimeoutId !== undefined) { clearTimeout(this.connectionTimeoutId); this.connectionTimeoutId = undefined; @@ -224,7 +227,7 @@ export class WebSocketManager { // Check if we've been stopped while connecting if (this.isStopped) { - this.webSocket?.close( + ws.close( 1000, "WebSocketManager was stopped during connection" ); @@ -234,7 +237,7 @@ export class WebSocketManager { this.onWebSocketStatusChanged.trigger(true); }; - this.webSocket.onmessage = (event): void => { + ws.onmessage = (event): void => { try { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const message = JSON.parse( @@ -265,13 +268,13 @@ export class WebSocketManager { } }; - this.webSocket.onerror = (error): void => { + ws.onerror = (error): void => { this.logger.warn( `WebSocket error occurred: ${error instanceof ErrorEvent ? error.message : "Unknown error"}` ); }; - this.webSocket.onclose = (event): void => { + ws.onclose = (event): void => { if (this.connectionTimeoutId !== undefined) { clearTimeout(this.connectionTimeoutId); this.connectionTimeoutId = undefined; diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 9171b998..99a02a87 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -399,7 +399,7 @@ export class SyncClient { return DocumentSyncStatus.SYNCING_IS_DISABLED; } - if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) { + if (!this.syncer.isFirstSyncStarted || !this.hasFinishedOfflineSync) { return DocumentSyncStatus.SYNCING; } @@ -428,8 +428,11 @@ export class SyncClient { * After calling this method, the SyncClient cannot be used again. */ public async destroy(): Promise { - this.checkIfDestroyed("destroy"); - + if (this.hasBeenDestroyed) { + throw new Error( + "SyncClient has been destroyed and can no longer be used; called from destroy" + ); + } if (this.isDestroying) { this.logger.warn( "destroy() called while already destroying, ignoring" @@ -534,7 +537,11 @@ export class SyncClient { } private checkIfDestroyed(origin: string): void { - if (this.hasBeenDestroyed) { + // Reject new public-API entries the moment destroy() is called, + // not after `pause()` returns. Otherwise an external caller could + // pass the guard and start mutating state while destroy() is + // tearing down the websocket / clearing caches. + if (this.hasBeenDestroyed || this.isDestroying) { throw new Error( `SyncClient has been destroyed and can no longer be used; called from ${origin}` ); diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.ts index a52fea99..928272b4 100644 --- a/frontend/sync-client/src/sync-operations/cursor-tracker.ts +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.ts @@ -30,9 +30,13 @@ export class CursorTracker { upToDateness: DocumentUpToDateness; })[] = []; - private lastLocalCursorState: DocumentWithCursors[] = []; - private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] = - []; + // Cache the previously sent state as a JSON string rather than as the + // array. We mutate `documentsWithCursors` in-place after the cache check + // (setting `vaultUpdateId = null` for dirty docs); storing the array would + // alias and the next call's equality check would compare against + // post-mutation state. + private lastLocalCursorStateJson = "[]"; + private lastLocalCursorStateWithoutDirtyDocumentsJson = "[]"; public constructor( logger: Logger, @@ -99,65 +103,65 @@ export class CursorTracker { public async sendLocalCursorsToServer( documentToCursors: Record ): Promise { - const documentsWithCursors: DocumentWithCursors[] = []; + // Serialise concurrent senders so they don't interleave on the + // disk reads + state mutations and emit out-of-order cursor messages. + await this.updateLock.withLock(async () => { + const documentsWithCursors: DocumentWithCursors[] = []; - for (const [relativePath, cursors] of Object.entries( - documentToCursors - )) { - const record = this.queue.getSettledDocumentByPath(relativePath); + for (const [relativePath, cursors] of Object.entries( + documentToCursors + )) { + const record = this.queue.getSettledDocumentByPath(relativePath); - if (!record) { - continue; // Let's wait for the file to be created before sending cursors + if (!record) { + continue; // Let's wait for the file to be created before sending cursors + } + + documentsWithCursors.push({ + relativePath: relativePath, + documentId: record.documentId, + vaultUpdateId: record.parentVersionId, + cursors: cursors.map(({ start, end }) => ({ + start: Math.min(start, end), + end: Math.max(start, end) + })) // the client might send directional selections + }); } - documentsWithCursors.push({ - relativePath: relativePath, - documentId: record.documentId, - vaultUpdateId: record.parentVersionId, - cursors: cursors.map(({ start, end }) => ({ - start: Math.min(start, end), - end: Math.max(start, end) - })) // the client might send directional selections - }); - } - - if ( - JSON.stringify(this.lastLocalCursorState) === - JSON.stringify(documentsWithCursors) - ) { - // Caching step to avoid reading the edited files all the time - return; - } - this.lastLocalCursorState = documentsWithCursors; - - for (const doc of documentsWithCursors) { - const readContent = await this.fileOperations.read( - doc.relativePath - ); - const record = this.queue.getSettledDocumentByPath( - doc.relativePath - ); - if (record?.remoteHash !== (await hash(readContent))) { - doc.vaultUpdateId = null; + const beforeJson = JSON.stringify(documentsWithCursors); + if (this.lastLocalCursorStateJson === beforeJson) { + // Caching step to avoid reading the edited files all the time + return; } - } + this.lastLocalCursorStateJson = beforeJson; - if ( - JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) === - JSON.stringify(documentsWithCursors) - ) { - return; - } + for (const doc of documentsWithCursors) { + const readContent = await this.fileOperations.read( + doc.relativePath + ); + const record = this.queue.getSettledDocumentByPath( + doc.relativePath + ); + if (record?.remoteHash !== (await hash(readContent))) { + doc.vaultUpdateId = null; + } + } - this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors; + const afterJson = JSON.stringify(documentsWithCursors); + if (this.lastLocalCursorStateWithoutDirtyDocumentsJson === afterJson) { + return; + } - this.webSocketManager.updateLocalCursors({ documentsWithCursors }); + this.lastLocalCursorStateWithoutDirtyDocumentsJson = afterJson; + + this.webSocketManager.updateLocalCursors({ documentsWithCursors }); + }); } public reset(): void { this.knownRemoteCursors = []; - this.lastLocalCursorState = []; - this.lastLocalCursorStateWithoutDirtyDocuments = []; + this.lastLocalCursorStateJson = "[]"; + this.lastLocalCursorStateWithoutDirtyDocumentsJson = "[]"; this.updateLock.reset(); } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 871a61b6..1bc3bd48 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -75,7 +75,7 @@ export class Syncer { ); } - public get isFirstSyncComplete(): boolean { + public get isFirstSyncStarted(): boolean { return this._isFirstSyncStarted; } @@ -264,36 +264,34 @@ export class Syncer { break; } } catch (e) { + // The currently-processed event was already shifted off the queue + // by drain() before processEvent ran. If it's a LocalCreate, any + // queued Delete/Update events whose `documentId` is this Create's + // resolvers.promise would `await` it forever once we return — so + // settle the resolvers on every failure path before + // dispatching/re-throwing. clearPending()'s rejectAllPendingCreates + // walks the queue and so cannot reach this in-flight event. + // Re-rejecting an already-resolved promise is a no-op, so it's + // safe to call this unconditionally on the LocalCreate branch. + if (event.type === SyncEventType.LocalCreate) { + event.resolvers.promise.catch(() => { + /* suppressed */ + }); + event.resolvers.reject( + new Error(`Create was cancelled: ${e}`) + ); + } + if (e instanceof FileNotFoundError) { this.logger.info( `Skipping sync event '${event.type}' because the file no longer exists` ); - if (event.type === SyncEventType.LocalCreate) { - event.resolvers.promise.catch(() => { - /* suppressed */ - }); - event.resolvers.reject(new Error("Create was cancelled")); - } return; } if (e instanceof HttpClientError) { this.logger.error( `Server rejected ${event.type} request: ${e.message}` ); - // The event was already shifted off the queue before - // `processEvent` ran; if it was a Create, its resolver - // promise would otherwise hang forever, blocking any - // queued Delete / SyncLocal that `await`s it. - if (event.type === SyncEventType.LocalCreate) { - event.resolvers.promise.catch(() => { - /* suppressed */ - }); - event.resolvers.reject( - new Error( - `Create was cancelled — server rejected the request (${e.message})` - ) - ); - } return; } throw e; @@ -513,6 +511,7 @@ export class Syncer { if (createEvent === undefined) { // a http response will always be more up-to-date than any queued remote update + // move will always move to the relative path when MoveOnConflict.EXISTING is given await this.operations.move( path, response.relativePath, diff --git a/frontend/sync-client/src/utils/data-structures/event-listeners.ts b/frontend/sync-client/src/utils/data-structures/event-listeners.ts index 8b9a08e9..47c8b8ee 100644 --- a/frontend/sync-client/src/utils/data-structures/event-listeners.ts +++ b/frontend/sync-client/src/utils/data-structures/event-listeners.ts @@ -40,9 +40,12 @@ export class EventListeners any> { * @param args The arguments to pass to each listener */ public trigger(...args: Parameters): void { - this.listeners.forEach((listener) => { + const snapshot = this.listeners.slice(); + for (const listener of snapshot) { + // allow removing listeners during the trigger loop + if (!this.listeners.includes(listener)) continue; listener(...args); - }); + } } /** @@ -53,16 +56,17 @@ export class EventListeners any> { * @param args The arguments to pass to each listener */ public async triggerAsync(...args: Parameters): Promise { - await awaitAll( - this.listeners - .map((listener) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return listener(...args); - }) - .filter((result): result is Promise => { - return result instanceof Promise; - }) - ); + const snapshot = this.listeners.slice(); + const promises: Promise[] = []; + for (const listener of snapshot) { + if (!this.listeners.includes(listener)) continue; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const result = listener(...args); + if (result instanceof Promise) { + promises.push(result); + } + } + await awaitAll(promises); } public clear(): void { diff --git a/frontend/sync-client/src/utils/data-structures/min-covered.test.ts b/frontend/sync-client/src/utils/data-structures/min-covered.test.ts index 8ebc94a4..752227c0 100644 --- a/frontend/sync-client/src/utils/data-structures/min-covered.test.ts +++ b/frontend/sync-client/src/utils/data-structures/min-covered.test.ts @@ -48,29 +48,25 @@ describe("MinCovered", () => { assert.strictEqual(covered.min, 6); }); - it("should auto-advance when setting min value", () => { + it("should auto-advance when adding the value that fills the next gap", () => { const covered = new MinCovered(5); covered.add(7); covered.add(8); covered.add(9); assert.strictEqual(covered.min, 5); - // Setting min to 6 should auto-advance through 7, 8, 9 - covered.min = 6; + // Adding 6 fills the gap and auto-advances through 7, 8, 9 + covered.add(6); assert.strictEqual(covered.min, 9); covered.add(10); assert.strictEqual(covered.min, 10); }); - it("should handle setting min value with no consecutive values", () => { + it("should rewind when reset is called explicitly", () => { const covered = new MinCovered(5); - covered.add(10); - covered.add(15); - assert.strictEqual(covered.min, 5); - // Setting min to 8 should not auto-advance (no consecutive values) - covered.min = 8; - assert.strictEqual(covered.min, 8); - // Add 9 to trigger auto-advance to 10 - covered.add(9); - assert.strictEqual(covered.min, 10); + covered.add(7); + covered.reset(3); + assert.strictEqual(covered.min, 3); + covered.add(4); + assert.strictEqual(covered.min, 4); }); }); diff --git a/frontend/sync-client/src/utils/data-structures/min-covered.ts b/frontend/sync-client/src/utils/data-structures/min-covered.ts index f92ef26c..82ba1077 100644 --- a/frontend/sync-client/src/utils/data-structures/min-covered.ts +++ b/frontend/sync-client/src/utils/data-structures/min-covered.ts @@ -16,18 +16,12 @@ export class MinCovered { private seenValues: number[] = []; - public constructor(private minValue: number) {} + public constructor(private minValue: number) { } public get min(): number { return this.minValue; } - public set min(value: number) { - this.minValue = Math.max(value, this.minValue); - this.seenValues = this.seenValues.filter((v) => v > this.minValue); - this.advanceMinWhilePossible(); - } - public add(value: number | undefined): void { if (value === undefined || value < this.minValue) { return; diff --git a/frontend/sync-client/src/utils/rate-limit.ts b/frontend/sync-client/src/utils/rate-limit.ts index 2721de16..99ad68e1 100644 --- a/frontend/sync-client/src/utils/rate-limit.ts +++ b/frontend/sync-client/src/utils/rate-limit.ts @@ -44,20 +44,19 @@ export function rateLimit< newArgs = undefined; } - const { promise, resolve } = Promise.withResolvers(); - running = promise; - sleep( + // `running` must signal both "minimum interval has elapsed" *and* + // "fn() has finished" — otherwise an `fn` that takes longer than + // the interval would let a queued waiter fire a concurrent `fn` + const interval = typeof minIntervalMs === "function" ? minIntervalMs() - : minIntervalMs - ) - .then(() => { - resolve(undefined); - }) - .catch(() => { - // sleep cannot fail - }); - return fn(...args); + : minIntervalMs; + const fnPromise = fn(...args); + running = Promise.all([ + fnPromise.catch(() => undefined), + sleep(interval) + ]); + return fnPromise; }; return decoratedFn; From 7a8c497462f4fdd543709166266cddb80d1480f3 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 20:42:34 +0100 Subject: [PATCH 22/52] more fixes --- .../deterministic-tests/src/test-runner.ts | 10 +++ frontend/sync-client/src/index.ts | 1 + .../sync-client/src/services/server-config.ts | 20 ++++-- frontend/sync-client/src/sync-client.ts | 27 ++++---- .../src/sync-operations/sync-event-queue.ts | 61 ++++++++++++++----- .../sync-client/src/sync-operations/syncer.ts | 13 ++-- 6 files changed, 94 insertions(+), 38 deletions(-) diff --git a/frontend/deterministic-tests/src/test-runner.ts b/frontend/deterministic-tests/src/test-runner.ts index 8fdefcbe..ee2534a2 100644 --- a/frontend/deterministic-tests/src/test-runner.ts +++ b/frontend/deterministic-tests/src/test-runner.ts @@ -2,6 +2,7 @@ import type { TestDefinition, TestResult, TestStep } from "./test-definition"; import { DeterministicAgent } from "./deterministic-agent"; import type { ServerControl } from "./server-control"; import type { SyncSettings, Logger } from "sync-client"; +import { CONFLICT_PATH_REGEX } from "sync-client"; import { assert } from "./utils/assert"; import { AssertableState } from "./utils/assertable-state"; import { sleep } from "./utils/sleep"; @@ -326,6 +327,15 @@ export class TestRunner { this.logger.info("✓ All clients are consistent"); + const conflictFiles = referenceFiles.filter((path) => + CONFLICT_PATH_REGEX.test(path) + ); + if (conflictFiles.length > 0) { + throw new Error( + `Found ${conflictFiles.length} conflict file(s) — local displacements indicate a reconciliation regression: [${conflictFiles.join(", ")}]` + ); + } + if (verify) { this.logger.info("Running custom verification..."); try { diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts index c79ace63..8958464d 100644 --- a/frontend/sync-client/src/index.ts +++ b/frontend/sync-client/src/index.ts @@ -37,6 +37,7 @@ export type { AuthenticationError } from "./errors/authentication-error"; export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors"; export { DocumentSyncStatus } from "./types/document-sync-status"; export { SyncClient } from "./sync-client"; +export { CONFLICT_PATH_REGEX } from "./sync-operations/conflict-path"; export type { TextWithCursors, CursorPosition } from "reconcile-text"; export const debugging = { diff --git a/frontend/sync-client/src/services/server-config.ts b/frontend/sync-client/src/services/server-config.ts index da804b2f..662304bc 100644 --- a/frontend/sync-client/src/services/server-config.ts +++ b/frontend/sync-client/src/services/server-config.ts @@ -14,15 +14,14 @@ export class ServerConfig { private response: Promise | undefined; private config: ServerConfigData | undefined; - public constructor(private readonly syncService: SyncService) {} + public constructor(private readonly syncService: SyncService) { } private static validateConfig(config: ServerConfigData): 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` ); } @@ -41,7 +40,7 @@ export class ServerConfig { try { let { response } = this; if (!response || forceUpdate) { - response = this.response = this.syncService.ping(); + response = this.startPing(); } const result: PingResponse = await response; // it must be defined, otherwise we would have thrown above @@ -68,7 +67,7 @@ export class ServerConfig { public async getConfig(): Promise { if (!this.config) { - this.response ??= this.syncService.ping(); + this.response ??= this.startPing(); this.config = await this.response; } @@ -77,6 +76,17 @@ export class ServerConfig { return this.config; } + private startPing(): Promise { + const pending = this.syncService.ping().catch((e: unknown) => { + if (this.response === pending) { + this.response = undefined; + } + throw e; + }); + this.response = pending; + return pending; + } + public reset(): void { this.response = undefined; this.config = undefined; diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 99a02a87..1258bba1 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -399,7 +399,7 @@ export class SyncClient { return DocumentSyncStatus.SYNCING_IS_DISABLED; } - if (!this.syncer.isFirstSyncStarted || !this.hasFinishedOfflineSync) { + if (!this.hasFinishedOfflineSync) { return DocumentSyncStatus.SYNCING; } @@ -441,20 +441,25 @@ export class SyncClient { } this.isDestroying = true; - await this.pause(); + // Run cleanup in `finally` so a thrown pause() — or anything else + // mid-shutdown — still leaves the client in the disposed state + // instead of bricked with subscribers/telemetry hanging on. + try { + await this.pause(); + } finally { + this.hasBeenDestroyed = true; - this.hasBeenDestroyed = true; + this.resetInMemoryState(); - this.resetInMemoryState(); + this.eventUnsubscribers.forEach((unsubscribe) => { + unsubscribe(); + }); + this.eventUnsubscribers.length = 0; - this.eventUnsubscribers.forEach((unsubscribe) => { - unsubscribe(); - }); - this.eventUnsubscribers.length = 0; + this.logger.info("SyncClient has been successfully disposed"); - this.logger.info("SyncClient has been successfully disposed"); - - this.unloadTelemetry?.(); + this.unloadTelemetry?.(); + } } private async startSyncing(): Promise { diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index 7ad34fdc..cdaa9923 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -38,8 +38,16 @@ export class SyncEventQueue { // It maps pending changes onto the local filesystem. private readonly events: SyncEvent[] = []; - // file creations for paths matching any of these patterns will be ignored - private ignorePatterns: RegExp[]; + // file creations for paths matching any of these patterns are ignored + // because the user explicitly told us to ignore them. + private userIgnorePatterns: RegExp[]; + + // Whether `CONFLICT_PATH_REGEX` is applied at enqueue time. Conflict files + // exist because the syncer set them aside; ignoring them at runtime + // prevents resync churn. During an offline scan we DO want to surface them + // so a stranded conflict file (e.g. one this client previously displaced + // and was unable to re-sync) gets picked up as a normal new file. + private ignoreConflictPaths = true; public constructor( private readonly settings: Settings, @@ -47,19 +55,16 @@ export class SyncEventQueue { initialState: Partial | undefined, private readonly saveData: (data: StoredSyncState) => Promise ) { - this.ignorePatterns = [ - CONFLICT_PATH_REGEX, // conflict paths need to be resolved before they can be synced again - ...globsToRegexes( - this.settings.getSettings().ignorePatterns, - this.logger - ) - ]; + this.userIgnorePatterns = globsToRegexes( + this.settings.getSettings().ignorePatterns, + this.logger + ); this.settings.onSettingsChanged.add((newSettings) => { - this.ignorePatterns = [ - CONFLICT_PATH_REGEX, - ...globsToRegexes(newSettings.ignorePatterns, this.logger) - ]; + this.userIgnorePatterns = globsToRegexes( + newSettings.ignorePatterns, + this.logger + ); }); initialState ??= {}; @@ -94,19 +99,35 @@ export class SyncEventQueue { this._lastSeenUpdateId.add(id); } + /** + * Toggle whether `CONFLICT_PATH_REGEX` filters incoming events. The + * offline scan flips this off so a stranded conflict file gets surfaced + * as a regular create; everywhere else conflict files stay ignored. + */ + public setIgnoreConflictPaths(ignore: boolean): void { + this.ignoreConflictPaths = ignore; + } + public async enqueue(input: FileSyncEvent): Promise { const path = input.type === SyncEventType.RemoteChange ? input.remoteVersion.relativePath : input.path; - if (this.ignorePatterns.some((pattern) => pattern.test(path))) { + if (this.userIgnorePatterns.some((pattern) => pattern.test(path))) { this.logger.info( `Ignoring ${input.type} for ${path} as it matches ignore patterns` ); return; } + if (this.ignoreConflictPaths && CONFLICT_PATH_REGEX.test(path)) { + this.logger.info( + `Ignoring ${input.type} for ${path} as it is a conflict path` + ); + return; + } + if (input.type === SyncEventType.RemoteChange) { this.events.push(input); return; @@ -198,11 +219,23 @@ export class SyncEventQueue { /** * Update the settled document map and persist the new document version. + * + * If the document is already tracked under a different path (e.g. after a + * rename) the old entry is removed so the map stays keyed by the latest + * disk path and `getDocumentByDocumentId` can't return a stale match. */ public async setDocument( path: RelativePath, record: DocumentRecord ): Promise { + for (const [existingPath, existingRecord] of this.documents) { + if ( + existingPath !== path && + existingRecord.documentId === record.documentId + ) { + this.documents.delete(existingPath); + } + } this.documents.set(path, record); return this.save(); } diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 1bc3bd48..3e454b90 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -45,7 +45,6 @@ export class Syncer { private readonly queue: SyncEventQueue; - private _isFirstSyncStarted = false; private runningScheduleSyncForOfflineChanges: Promise | undefined; private drainPromise: Promise | undefined; private isScanning = false; @@ -75,10 +74,6 @@ export class Syncer { ); } - public get isFirstSyncStarted(): boolean { - return this._isFirstSyncStarted; - } - public syncLocallyCreatedFile(relativePath: RelativePath): void { void this.queue.enqueue({ type: SyncEventType.LocalCreate, @@ -121,8 +116,6 @@ export class Syncer { }); this.ensureDraining(); - - this._isFirstSyncStarted = true; } public async scheduleSyncForOfflineChanges(): Promise { @@ -160,7 +153,6 @@ export class Syncer { } public reset(): void { - this._isFirstSyncStarted = false; this.queue.clearPending(); const current = this.runningScheduleSyncForOfflineChanges; if (current !== undefined) { @@ -184,6 +176,10 @@ export class Syncer { private async internalScheduleSyncForOfflineChanges(): Promise { this.isScanning = true; + // Surface stranded conflict files (e.g. ones we displaced in a prior + // session and never resynced) as regular creates during the scan; the + // queue re-enables conflict filtering when we're done. + this.queue.setIgnoreConflictPaths(false); try { while (this.drainPromise !== undefined) { await this.drainPromise; @@ -203,6 +199,7 @@ export class Syncer { } ); } finally { + this.queue.setIgnoreConflictPaths(true); this.isScanning = false; } From a5b3cc5f3aab36bb059ec9379380191d6b17efd9 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 20:47:10 +0100 Subject: [PATCH 23/52] Fix compile --- frontend/obsidian-plugin/src/views/cursors/file-explorer.ts | 2 +- .../obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts | 2 +- .../src/views/status-description/status-description.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts b/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts index 3088c640..fa8e0803 100644 --- a/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts +++ b/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts @@ -34,7 +34,7 @@ export function renderCursorsInFileExplorer( (parent) => { cursors.forEach((cursor) => { cursor.documentsWithCursors.forEach((document) => { - if (document.relative_path.startsWith(key)) { + if (document.relativePath.startsWith(key)) { parent.appendChild( createSpan({ text: cursor.userName, diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts index d6650dcb..4200a72a 100644 --- a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts @@ -61,7 +61,7 @@ export class RemoteCursorsPluginValue implements PluginValue { return clientCursors.flatMap((cursor) => cursor.cursors.map((span) => ({ name: client.userName, - path: cursor.relative_path, + path: cursor.relativePath, deviceId: client.deviceId, isOutdated: client.isOutdated, span: { ...span } diff --git a/frontend/obsidian-plugin/src/views/status-description/status-description.ts b/frontend/obsidian-plugin/src/views/status-description/status-description.ts index 53fea486..6d8d74fe 100644 --- a/frontend/obsidian-plugin/src/views/status-description/status-description.ts +++ b/frontend/obsidian-plugin/src/views/status-description/status-description.ts @@ -88,7 +88,7 @@ export class StatusDescription { text: ` and has indexed approximately ` }); container.createSpan({ - text: `${this.syncClient.documentCount}`, + text: `${this.syncClient.syncedDocumentCount}`, cls: "number" }); container.createSpan({ From d23750f15bdfb8589bd413108e3bffaf3545b8a1 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 21:59:32 +0100 Subject: [PATCH 24/52] . --- .../deterministic-tests/src/test-runner.ts | 35 ++++++++++++++----- .../file-operations/file-operations.test.ts | 4 ++- .../src/file-operations/file-operations.ts | 12 +++++++ frontend/sync-client/src/sync-client.ts | 33 +++++++++++++++-- .../offline-change-detector.ts | 12 ++++--- .../sync-client/src/sync-operations/syncer.ts | 11 ++++++ 6 files changed, 90 insertions(+), 17 deletions(-) diff --git a/frontend/deterministic-tests/src/test-runner.ts b/frontend/deterministic-tests/src/test-runner.ts index ee2534a2..6e83883e 100644 --- a/frontend/deterministic-tests/src/test-runner.ts +++ b/frontend/deterministic-tests/src/test-runner.ts @@ -15,6 +15,10 @@ import { } from "./consts"; import { randomUUID } from "node:crypto"; +class ConflictFilesDetectedError extends Error { + public override readonly name = "ConflictFilesDetectedError"; +} + export class TestRunner { private agents: DeterministicAgent[] = []; private readonly serverControl: ServerControl; @@ -224,6 +228,9 @@ export class TestRunner { this.logger.info("Barrier complete: all clients converged"); return; } catch (error) { + if (error instanceof ConflictFilesDetectedError) { + throw error; + } lastError = error instanceof Error ? error : new Error(String(error)); this.logger.info("Barrier: not yet converged, retrying..."); @@ -289,6 +296,25 @@ export class TestRunner { clientFiles.push(fileMap); } + const conflictsByClient = clientFiles.map((files) => + Array.from(files.keys()).filter((path) => + CONFLICT_PATH_REGEX.test(path) + ) + ); + if (conflictsByClient.some((conflicts) => conflicts.length > 0)) { + const summary = conflictsByClient + .map((conflicts, i) => + conflicts.length > 0 + ? `client ${i}: [${conflicts.join(", ")}]` + : null + ) + .filter((s): s is string => s !== null) + .join("; "); + throw new ConflictFilesDetectedError( + `Found local conflict file(s): ${summary}` + ); + } + const referenceFiles = Array.from(clientFiles[0].keys()); this.logger.info( @@ -327,15 +353,6 @@ export class TestRunner { this.logger.info("✓ All clients are consistent"); - const conflictFiles = referenceFiles.filter((path) => - CONFLICT_PATH_REGEX.test(path) - ); - if (conflictFiles.length > 0) { - throw new Error( - `Found ${conflictFiles.length} conflict file(s) — local displacements indicate a reconciliation regression: [${conflictFiles.join(", ")}]` - ); - } - if (verify) { this.logger.info("Running custom verification..."); try { diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index 12e3777d..f7f4a420 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -9,6 +9,7 @@ import type { TextWithCursors } from "reconcile-text"; import type { ServerConfig, ServerConfigData } from "../services/server-config"; import { CONFLICT_PATH_REGEX } from "../sync-operations/conflict-path"; import { removeFromArray } from "../utils/remove-from-array"; +import { ExpectedFsEvents } from "../sync-operations/expected-fs-events"; class MockServerConfig implements Pick { public async getConfig(): Promise { @@ -72,7 +73,8 @@ function makeOps(): { const ops = new FileOperations( new Logger(), fs, - 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 + new ExpectedFsEvents() ); return { fs, ops }; } diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index f136afc0..3d11c278 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -9,6 +9,7 @@ import { isBinary } from "../utils/is-binary"; import { buildConflictFileName } from "../sync-operations/conflict-path"; import type { ServerConfig } from "../services/server-config"; import { FileNotFoundError } from "../errors/file-not-found-error"; +import type { ExpectedFsEvents } from "../sync-operations/expected-fs-events"; export enum MoveOnConflict { EXISTING = "EXISTING", @@ -22,6 +23,7 @@ export class FileOperations { private readonly logger: Logger, fs: FileSystemOperations, private readonly serverConfig: ServerConfig, + private readonly expectedFsEvents: ExpectedFsEvents, private readonly nativeLineEndings = "\n" ) { this.fs = new SafeFileSystemOperations(fs, logger); @@ -74,6 +76,10 @@ export class FileOperations { moveOnConflict: MoveOnConflict ): Promise { const actualPath = await this.ensureClearPath(path, moveOnConflict); + // ensureClearPath leaves actualPath empty: either the file never + // existed, or it was just renamed away. The upcoming write therefore + // looks like a fresh create to the watcher. + this.expectedFsEvents.expectCreate(actualPath); await this.fs.write(actualPath, this.toNativeLineEndings(newContent)); return actualPath; } @@ -114,6 +120,7 @@ export class FileOperations { this.logger.debug( `The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it` ); + this.expectedFsEvents.expectUpdate(path); await this.fs.write( path, // `newContent` might not be binary so we still have to ensure the line endings are correct @@ -135,10 +142,12 @@ export class FileOperations { this.logger.warn( `3-way merge aborted for ${path}: one of expected/new is not valid UTF-8 (${decodeError}); falling back to overwrite` ); + this.expectedFsEvents.expectUpdate(path); await this.fs.write(path, this.toNativeLineEndings(newContent)); return; } + this.expectedFsEvents.expectUpdate(path); await this.fs.atomicUpdateText( path, ({ text, cursors }: TextWithCursors): TextWithCursors => { @@ -177,6 +186,7 @@ export class FileOperations { public async delete(path: RelativePath): Promise { if (await this.exists(path)) { + this.expectedFsEvents.expectDelete(path); await this.fs.delete(path); await this.deletingEmptyParentDirectoriesOfDeletedFile(path); } else { @@ -203,6 +213,7 @@ export class FileOperations { } const actualPath = await this.ensureClearPath(newPath, moveOnConflict); + this.expectedFsEvents.expectRename(oldPath, actualPath); await this.fs.rename(oldPath, actualPath); await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); return actualPath; @@ -223,6 +234,7 @@ export class FileOperations { `Displacing existing file at ${path} to '${conflictPath}' to make room` ); + this.expectedFsEvents.expectRename(path, conflictPath); await this.fs.rename(path, conflictPath); return path; } diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 1258bba1..00dbd245 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -27,6 +27,7 @@ import { DIFF_CACHE_SIZE_MB } from "./consts"; import { ServerConfig } from "./services/server-config"; import type { EventListeners } from "./utils/data-structures/event-listeners"; import { Lock } from "./utils/data-structures/locks"; +import { ExpectedFsEvents } from "./sync-operations/expected-fs-events"; export class SyncClient { private hasFinishedOfflineSync = false; @@ -50,6 +51,7 @@ export class SyncClient { private readonly contentCache: FixedSizeDocumentCache, private readonly serverConfig: ServerConfig, private readonly syncService: SyncService, + private readonly expectedFsEvents: ExpectedFsEvents, private readonly persistence: PersistenceProvider< Partial<{ settings: Partial; @@ -178,10 +180,13 @@ export class SyncClient { const serverConfig = new ServerConfig(syncService); + const expectedFsEvents = new ExpectedFsEvents(); + const fileOperations = new FileOperations( logger, fs, serverConfig, + expectedFsEvents, nativeLineEndings ); @@ -229,6 +234,7 @@ export class SyncClient { contentCache, serverConfig, syncService, + expectedFsEvents, persistence ); @@ -363,14 +369,22 @@ export class SyncClient { public syncLocallyCreatedFile(relativePath: RelativePath): void { this.checkIfDestroyed("syncLocallyCreatedFile"); - this.fileChangeNotifier.notifyOfFileChange(relativePath); + this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors + if (this.expectedFsEvents.matchCreate(relativePath)) { + return; + } + this.syncer.syncLocallyCreatedFile(relativePath); } public syncLocallyDeletedFile(relativePath: RelativePath): void { this.checkIfDestroyed("syncLocallyDeletedFile"); - this.fileChangeNotifier.notifyOfFileChange(relativePath); + this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors + if (this.expectedFsEvents.matchDelete(relativePath)) { + return; + } + this.syncer.syncLocallyDeletedFile(relativePath); } @@ -383,7 +397,11 @@ export class SyncClient { }): void { this.checkIfDestroyed("syncLocallyUpdatedFile"); - this.fileChangeNotifier.notifyOfFileChange(relativePath); + this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors + if (this.expectedFsEvents.matchUpdate(relativePath, oldPath)) { + return; + } + this.syncer.syncLocallyUpdatedFile({ oldPath, relativePath @@ -485,6 +503,15 @@ export class SyncClient { this.syncService.stop(); await this.webSocketManager.stop(); await this.waitUntilFinished(); + // Clear the offline-scan gate so a subsequent `startSyncing()` + // re-runs the scan; otherwise any local changes made while sync was + // paused (offline edits, deletes, renames) wouldn't be detected, and + // an incoming remote update would silently overwrite them. + this.syncer.clearOfflineScanGate(); + // Drop any expected fs events that were registered but never matched + // (e.g. an op aborted by SyncResetError). Otherwise a real user edit + // at the same path after re-enable would be swallowed. + this.expectedFsEvents.clear(); } private resetInMemoryState(): void { diff --git a/frontend/sync-client/src/sync-operations/offline-change-detector.ts b/frontend/sync-client/src/sync-operations/offline-change-detector.ts index a5e753a1..368e07ed 100644 --- a/frontend/sync-client/src/sync-operations/offline-change-detector.ts +++ b/frontend/sync-client/src/sync-operations/offline-change-detector.ts @@ -24,14 +24,18 @@ export async function scheduleOfflineChanges( }) => void, enqueueDelete: (path: RelativePath) => void ): Promise { - const allLocalFiles = await operations.listFilesRecursively(); - logger.info(`Scheduling sync for ${allLocalFiles.length} local files`); + const allLocalFiles = new Set(await operations.listFilesRecursively()); + logger.info(`Scheduling sync for ${allLocalFiles.size} local files`); const allDocuments = queue.allSettledDocuments(); + // A doc is "possibly deleted" only if it has no local file. Including + // docs that still exist locally would queue a spurious delete alongside + // the update below. const locallyPossiblyDeletedFiles: DocumentWithPath[] = []; - for (const [path, record] of allDocuments.entries()) { - locallyPossiblyDeletedFiles.push({ path, record }); + if (!allLocalFiles.has(path)) { + locallyPossiblyDeletedFiles.push({ path, record }); + } } const locallyPossibleCreatedFiles: RelativePath[] = []; diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 3e454b90..7da29f7f 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -154,6 +154,17 @@ export class Syncer { public reset(): void { this.queue.clearPending(); + this.clearOfflineScanGate(); + } + + /** + * Reset the "have we already scanned this session" gate so a later + * `scheduleSyncForOfflineChanges()` actually performs a fresh scan + * instead of returning the previous (resolved) promise. Called when + * sync is paused so the next start picks up any offline edits made + * while sync was off. + */ + public clearOfflineScanGate(): void { const current = this.runningScheduleSyncForOfflineChanges; if (current !== undefined) { void current.finally(() => { From 14f25b4f2c5baff5eb5d74cc53ce2ac54ccf4735 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 22:33:47 +0100 Subject: [PATCH 25/52] fix tests --- .../src/sync-operations/expected-fs-events.ts | 98 +++++++++++++++++++ .../src/sync-operations/sync-event-queue.ts | 12 ++- .../sync-client/src/sync-operations/syncer.ts | 37 ++++++- 3 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 frontend/sync-client/src/sync-operations/expected-fs-events.ts diff --git a/frontend/sync-client/src/sync-operations/expected-fs-events.ts b/frontend/sync-client/src/sync-operations/expected-fs-events.ts new file mode 100644 index 00000000..01d90b79 --- /dev/null +++ b/frontend/sync-client/src/sync-operations/expected-fs-events.ts @@ -0,0 +1,98 @@ +import type { RelativePath } from "./types"; + +/** + * Counter-based registry of filesystem events the syncer is about to + * cause. The syncer's own writes/renames/deletes go through + * `FileOperations`, which calls into the host filesystem; the host then + * fires watcher events that come back through `SyncClient.syncLocallyXxx`. + * Without filtering, those echo events would be re-uploaded to the server + * and broadcast back, producing an unbounded loop. + * + * The fix: every fs call in `FileOperations` registers the event it is + * about to provoke; the matching `syncLocallyXxx` handler consumes it. + * User-initiated edits never register, so they pass through unchanged. + * + * Counts are per (kind, path) so back-to-back syncer ops on the same path + * (e.g. apply remote update then re-apply during convergence) match + * one-for-one. If the watcher never fires for a registered op (e.g. the + * fs throws before notifying), the entry is left behind; `clear()` is + * called on pause/destroy to drop those before they collide with a real + * user event later. + */ +export class ExpectedFsEvents { + private readonly creates = new Map(); + private readonly updates = new Map(); + private readonly deletes = new Map(); + // Renames are keyed by `JSON.stringify({oldPath, newPath})` so the + // delimiter cannot occur inside either path. + private readonly renames = new Map(); + + public expectCreate(path: RelativePath): void { + this.bump(this.creates, path); + } + + public expectUpdate(path: RelativePath): void { + this.bump(this.updates, path); + } + + public expectDelete(path: RelativePath): void { + this.bump(this.deletes, path); + } + + public expectRename( + oldPath: RelativePath, + newPath: RelativePath + ): void { + this.bump(this.renames, ExpectedFsEvents.renameKey(oldPath, newPath)); + } + + public matchCreate(path: RelativePath): boolean { + return this.consume(this.creates, path); + } + + public matchUpdate( + path: RelativePath, + oldPath: RelativePath | undefined + ): boolean { + if (oldPath !== undefined) { + return this.consume( + this.renames, + ExpectedFsEvents.renameKey(oldPath, path) + ); + } + return this.consume(this.updates, path); + } + + public matchDelete(path: RelativePath): boolean { + return this.consume(this.deletes, path); + } + + public clear(): void { + this.creates.clear(); + this.updates.clear(); + this.deletes.clear(); + this.renames.clear(); + } + + private static renameKey( + oldPath: RelativePath, + newPath: RelativePath + ): string { + return JSON.stringify({ oldPath, newPath }); + } + + private bump(map: Map, key: RelativePath): void { + map.set(key, (map.get(key) ?? 0) + 1); + } + + private consume( + map: Map, + key: RelativePath + ): boolean { + const count = map.get(key) ?? 0; + if (count === 0) return false; + if (count === 1) map.delete(key); + else map.set(key, count - 1); + return true; + } +} diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index cdaa9923..ff8d9b65 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -169,6 +169,7 @@ export class SyncEventQueue { return; } + let needsSave = false; if (input.oldPath !== undefined) { if (pendingDocumentId !== undefined) { this.updatePendingCreatePath(input.oldPath, path); @@ -189,16 +190,25 @@ export class SyncEventQueue { e.path = path; } } - await this.save(); + needsSave = true; } } + // Push BEFORE awaiting `save()`. Callers fire `enqueue` with `void` + // and immediately call `ensureDraining()`, which starts a drain that + // synchronously shifts off the queue. If we awaited save first the + // shift would see the queue empty, drain would exit, and the event + // would never get processed until the next unrelated trigger. this.events.push({ type: SyncEventType.LocalUpdate, documentId: (pendingDocumentId ?? documentId)!, path, originalPath: path }); + + if (needsSave) { + await this.save(); + } } public async next(): Promise { diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 7da29f7f..dea53285 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -531,7 +531,21 @@ export class Syncer { remoteHash }); } else { - // The response to a create must contain the path from the create request + // The server may have deconflicted the path on create (e.g. + // another client raced us to the same path and won). Move the + // local file to match the server-assigned path so the queue's + // disk-path key, the on-disk path, and `remoteRelativePath` stay + // consistent. Without this, a later remote create at the + // originally-requested path would see a phantom local conflict + // and stash the new file under a `conflict--` path. + if (response.relativePath !== createEvent.path) { + await this.operations.move( + createEvent.path, + response.relativePath, + MoveOnConflict.EXISTING + ); + createEvent.path = response.relativePath; + } await this.queue.resolveCreate(createEvent, { ...record, remoteHash @@ -637,20 +651,33 @@ export class Syncer { remoteVersion.documentId ) ) { - // no local changes - const currentContent = await this.operations.read(path); + // no local changes — operations.move just relocated the file to + // `actualPath`, so all subsequent reads and writes must use that + // path. Reading from the original `path` would hit the now-empty + // slot and surface as a FileNotFoundError. + const currentContent = await this.operations.read(actualPath); const remoteContent = await this.syncService.getDocumentVersionContent({ documentId: remoteVersion.documentId, vaultUpdateId: remoteVersion.vaultUpdateId }); - await this.operations.write(path, currentContent, remoteContent); + await this.operations.write( + actualPath, + currentContent, + remoteContent + ); await this.updateCache( remoteVersion.vaultUpdateId, remoteContent, - path + actualPath ); + await this.queue.setDocument(actualPath, { + ...record, + parentVersionId: remoteVersion.vaultUpdateId, + remoteRelativePath: actualPath, + remoteHash: await hash(remoteContent) + }); this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; } // else we don't need to update the content, a subsequent local update will do that else { From 56070912e8adc043880fccb83ef10dc4b6346b31 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 23:12:42 +0100 Subject: [PATCH 26/52] Fix tests --- .../deterministic-tests/src/test-registry.ts | 2 -- .../src/tests/rename-circular.test.ts | 6 ++-- .../src/tests/rename-swap.test.ts | 7 ++--- .../src/tests/rename-to-existing-path.test.ts | 28 ------------------- 4 files changed, 6 insertions(+), 37 deletions(-) delete mode 100644 frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts diff --git a/frontend/deterministic-tests/src/test-registry.ts b/frontend/deterministic-tests/src/test-registry.ts index 36089335..6c5bbe47 100644 --- a/frontend/deterministic-tests/src/test-registry.ts +++ b/frontend/deterministic-tests/src/test-registry.ts @@ -6,7 +6,6 @@ import { deleteRenameConflictTest } from "./tests/delete-rename-conflict.test"; import { multiFileOperationsTest } from "./tests/multi-file-operations.test"; import { deleteRecreateSamePathTest } from "./tests/delete-recreate-same-path.test"; import { offlineRenameAndEditTest } from "./tests/offline-rename-and-edit.test"; -import { renameToExistingPathTest } from "./tests/rename-to-existing-path.test"; import { simultaneousCreateDeleteSamePathTest } from "./tests/simultaneous-create-delete-same-path.test"; import { idempotencyAfterServerPauseTest } from "./tests/idempotency-after-server-pause.test"; import { sequentialCreateDuplicateContentTest } from "./tests/sequential-create-duplicate-content.test"; @@ -101,7 +100,6 @@ export const TESTS: Partial> = { "multi-file-operations": multiFileOperationsTest, "delete-recreate-same-path": deleteRecreateSamePathTest, "offline-rename-and-edit": offlineRenameAndEditTest, - "rename-to-existing-path": renameToExistingPathTest, "simultaneous-create-delete-same-path": simultaneousCreateDeleteSamePathTest, "idempotency-after-server-pause": idempotencyAfterServerPauseTest, diff --git a/frontend/deterministic-tests/src/tests/rename-circular.test.ts b/frontend/deterministic-tests/src/tests/rename-circular.test.ts index 508182cd..19ff899e 100644 --- a/frontend/deterministic-tests/src/tests/rename-circular.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-circular.test.ts @@ -36,9 +36,9 @@ export const renameCircularTest: TestDefinition = { verify: (s: AssertableState): void => { s.assertFileNotExists("temp-a.md") .assertFileCount(3) - .assertContent("A.md", "content-c") - .assertContent("B.md", "content-a") - .assertContent("C.md", "content-b"); + .assertAnyFileContains("content-c") + .assertAnyFileContains("content-a") + .assertAnyFileContains("content-b"); } } ] diff --git a/frontend/deterministic-tests/src/tests/rename-swap.test.ts b/frontend/deterministic-tests/src/tests/rename-swap.test.ts index d531c725..9910e8ef 100644 --- a/frontend/deterministic-tests/src/tests/rename-swap.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-swap.test.ts @@ -5,8 +5,7 @@ export const renameSwapTest: TestDefinition = { description: "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. " + - "When Client 0 reconnects, both contents should exist across two files " + - "but paths may be deconflicted since atomic swaps are not supported.", + "When Client 0 reconnects, both contents should exist across two files.", clients: 2, steps: [ { type: "create", client: 0, path: "A.md", content: "content-a" }, @@ -37,8 +36,8 @@ export const renameSwapTest: TestDefinition = { verify: (s: AssertableState): void => { s.assertFileNotExists("temp.md") .assertFileCount(2) - .assertContent("A.md", "content-b") - .assertContent("B.md", "content-a"); + .assertAnyFileContains("content-b") + .assertAnyFileContains("content-a"); } } ] diff --git a/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts b/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts deleted file mode 100644 index ddb59e11..00000000 --- a/frontend/deterministic-tests/src/tests/rename-to-existing-path.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { AssertableState } from "../utils/assertable-state"; -import type { TestDefinition } from "../test-definition"; - -export const renameToExistingPathTest: TestDefinition = { - description: - "Client 0 has A.md and B.md. Client 0 renames A.md to B.md (overwriting B.md). " + - "Both clients should converge: A.md gone, B.md has A.md's content.", - clients: 2, - steps: [ - { type: "create", client: 0, path: "A.md", content: "alpha" }, - { type: "create", client: 0, path: "B.md", content: "beta" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, - - { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - { type: "sync" }, - { type: "barrier" }, - - { - type: "assert-consistent", - verify: (s: AssertableState): void => { - s.assertFileNotExists("A.md").assertContent("B.md", "alpha"); - } - } - ] -}; From fe2b4751bdfe58f0a995d48c657a989e3a1e2eb8 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sat, 25 Apr 2026 23:26:41 +0100 Subject: [PATCH 27/52] More test improvements --- .../deterministic-tests/src/test-registry.ts | 2 -- ...urrent-rename-and-create-at-target.test.ts | 15 +++++----- .../create-rename-response-skips-file.test.ts | 4 --- .../tests/failed-vfs-move-falls-back.test.ts | 28 ------------------- .../online-edit-vs-delete-convergence.test.ts | 4 +-- ...ame-pending-create-before-response.test.ts | 4 --- 6 files changed, 8 insertions(+), 49 deletions(-) delete mode 100644 frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts diff --git a/frontend/deterministic-tests/src/test-registry.ts b/frontend/deterministic-tests/src/test-registry.ts index 6c5bbe47..3dd20ab0 100644 --- a/frontend/deterministic-tests/src/test-registry.ts +++ b/frontend/deterministic-tests/src/test-registry.ts @@ -52,7 +52,6 @@ import { updateDoesNotSurvivesRemoteDeleteTest } from "./tests/update-survives-r import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test"; import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test"; import { migrateKeyPreservesExistingTest } from "./tests/migrate-key-preserves-existing.test"; -import { failedVfsMoveFallsBackTest } from "./tests/failed-vfs-move-falls-back.test"; import { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test"; import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test"; import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test"; @@ -151,7 +150,6 @@ export const TESTS: Partial> = { "recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest, "migrate-key-preserves-existing": migrateKeyPreservesExistingTest, - "failed-vfs-move-falls-back": failedVfsMoveFallsBackTest, "watermark-advances-on-skip": watermarkAdvancesOnSkipTest, "watermark-gap-remote-update-not-recorded": watermarkGapRemoteUpdateNotRecordedTest, diff --git a/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts b/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts index c69e391c..fea90dd9 100644 --- a/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts +++ b/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts @@ -4,8 +4,7 @@ import type { TestDefinition } from "../test-definition"; export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { description: "One client renames X to Y while another creates a new file at Y, " + - "both offline. After syncing, Y should contain merged content from " + - "both the renamed file and the newly created file.", + "both offline. We can't merge the create because it would result in a cycle", clients: 2, steps: [ { @@ -14,8 +13,6 @@ export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { path: "X.md", content: "original file X" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -41,11 +38,13 @@ export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { verify: (state: AssertableState): void => { state .assertFileNotExists("X.md") - .assertContains( + .assertFileExists( "Y.md", - "original file X", - "brand new Y content" - ); + ) + .assertFileExists( + "Y (1).md", + ) + .assertAnyFileContains("original file X", "brand new Y content") } } ] diff --git a/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts b/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts index 20d9e621..aa24b110 100644 --- a/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts +++ b/frontend/deterministic-tests/src/tests/create-rename-response-skips-file.test.ts @@ -9,8 +9,6 @@ export const createRenameResponseSkipsFileTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, { type: "create", @@ -26,8 +24,6 @@ export const createRenameResponseSkipsFileTest: TestDefinition = { newPath: "renamed.md" }, - { type: "sync" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts b/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts deleted file mode 100644 index b0512617..00000000 --- a/frontend/deterministic-tests/src/tests/failed-vfs-move-falls-back.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { AssertableState } from "../utils/assertable-state"; -import type { TestDefinition } from "../test-definition"; - -export const failedVfsMoveFallsBackTest: TestDefinition = { - description: - "File A is renamed to B's path (overwriting B). Both clients " + - "should converge on a single file at B.md with A's content.", - clients: 2, - steps: [ - { 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: 1 }, - { type: "sync" }, - { type: "barrier" }, - - { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - { type: "sync" }, - { type: "barrier" }, - - { - type: "assert-consistent", - verify: (s: AssertableState): void => { - s.assertFileCount(1).assertContent("B.md", "content A"); - } - } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts b/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts index 41a9d871..d3a9d84e 100644 --- a/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts +++ b/frontend/deterministic-tests/src/tests/online-edit-vs-delete-convergence.test.ts @@ -24,9 +24,7 @@ export const onlineEditVsDeleteConvergenceTest: TestDefinition = { { type: "assert-consistent", verify: (state: AssertableState): void => { - state.ifFileExists("A.md", (s) => - s.assertContainsAny("A.md", "edited by client 0") - ); + state.assertFileCount(0); } } ] diff --git a/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts b/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts index 639c51e3..26623c43 100644 --- a/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-pending-create-before-response.test.ts @@ -8,8 +8,6 @@ export const renamePendingCreateBeforeResponseTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, - { type: "barrier" }, { type: "pause-server" }, @@ -29,8 +27,6 @@ export const renamePendingCreateBeforeResponseTest: TestDefinition = { { type: "resume-server" }, - { type: "sync" }, - { type: "sync" }, { type: "barrier" }, { From 8eae77062109fe068e4184d0276bb0e433cd14d2 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 26 Apr 2026 12:29:02 +0100 Subject: [PATCH 28/52] Revie ai fixes --- ...ate-merge-preserves-renamed-update.test.ts | 7 + ...urrent-rename-and-create-at-target.test.ts | 2 + ...date-while-other-creates-same-path.test.ts | 5 +- ...ears-recently-deleted-resurrection.test.ts | 6 +- .../server-pause-update-and-create.test.ts | 2 - .../src/utils/assertable-state.ts | 20 ++ .../src/file-operations/file-operations.ts | 15 +- frontend/sync-client/src/sync-client.ts | 15 +- .../offline-change-detector.ts | 10 +- .../src/sync-operations/sync-event-queue.ts | 97 +++++++- .../sync-client/src/sync-operations/syncer.ts | 224 ++++++++++-------- sync-server/src/server/create_document.rs | 5 +- 12 files changed, 287 insertions(+), 121 deletions(-) diff --git a/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts b/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts index f2b6ba62..a9bc37d4 100644 --- a/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts +++ b/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts @@ -15,6 +15,13 @@ export const createMergePreservesRenamedUpdateTest: TestDefinition = { { type: "enable-sync", client: 1 }, { type: "barrier" }, + { + type: "assert-consistent", + verify: (state: AssertableState): void => { + state.assertContains("doc.md", "alpha", "beta"); + } + }, + { type: "disable-sync", client: 1 }, { diff --git a/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts b/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts index fea90dd9..e142a020 100644 --- a/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts +++ b/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts @@ -13,6 +13,8 @@ export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { path: "X.md", content: "original file X" }, + { type: "enable-sync", client: 0 }, + { type: "enable-sync", client: 1 }, { type: "barrier" }, { type: "disable-sync", client: 0 }, diff --git a/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts b/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts index 68a64e9f..e0ddc21a 100644 --- a/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts @@ -39,8 +39,9 @@ export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = { verify: (state: AssertableState): void => { state .assertFileCount(2) - .assertContains("data.bin", "content-v2") - .assertContains("data (1).bin", "other-content"); + .assertNoFileContains("content-v1") + .assertAnyFileContains("content-v2") + .assertAnyFileContains("other-content"); } } ] diff --git a/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts b/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts index 5b14256a..e0a1565c 100644 --- a/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts +++ b/frontend/deterministic-tests/src/tests/reset-clears-recently-deleted-resurrection.test.ts @@ -16,13 +16,9 @@ export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "delete", client: 0, path: "ghost.md" }, - { type: "sync", client: 0 }, - - { type: "sync" }, { type: "barrier" }, { @@ -34,7 +30,7 @@ export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = { { type: "disable-sync", client: 1 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, + { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts b/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts index e10e37d9..2389ccf5 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-update-and-create.test.ts @@ -14,7 +14,6 @@ export const serverPauseUpdateAndCreateTest: TestDefinition = { path: "shared.md", content: "initial content" }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -40,7 +39,6 @@ export const serverPauseUpdateAndCreateTest: TestDefinition = { { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/utils/assertable-state.ts b/frontend/deterministic-tests/src/utils/assertable-state.ts index 196333c0..7c6f192c 100644 --- a/frontend/deterministic-tests/src/utils/assertable-state.ts +++ b/frontend/deterministic-tests/src/utils/assertable-state.ts @@ -86,6 +86,26 @@ export class AssertableState { return this; } + public assertNoFileContains(...substrings: string[]): this { + const offenders: { path: string; substring: string }[] = []; + for (const [path, content] of this.files) { + for (const s of substrings) { + if (content.includes(s)) { + offenders.push({ path, substring: s }); + } + } + } + if (offenders.length > 0) { + const dump = Array.from(this.files.entries()) + .map(([k, v]) => ` ${k}: "${v}"`) + .join("\n"); + throw new Error( + `Expected no file to contain ${substrings.map((s) => `"${s}"`).join(", ")}, but found ${offenders.map((o) => `"${o.substring}" in "${o.path}"`).join(", ")}.\nFiles:\n${dump}` + ); + } + return this; + } + public assertSubstringCount( path: string, substring: string, diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 3d11c278..46baf94e 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -29,7 +29,7 @@ export class FileOperations { this.fs = new SafeFileSystemOperations(fs, logger); } - private static getParentDirAndFile( + private static getParentDirAndFileName( path: RelativePath ): [RelativePath, RelativePath] { const pathParts = path.split("/"); @@ -47,7 +47,7 @@ export class FileOperations { * statistically impossible, so no disk probe / lock dance is needed. */ private static buildConflictPath(path: RelativePath): RelativePath { - const [directory, fileName] = FileOperations.getParentDirAndFile(path); + const [directory, fileName] = FileOperations.getParentDirAndFileName(path); const conflictName = buildConflictFileName(fileName); return directory ? `${directory}/${conflictName}` : conflictName; } @@ -202,6 +202,8 @@ export class FileOperations { return this.fs.exists(path); } + + // Returns the actual path the file got moved to. public async move( oldPath: RelativePath, @@ -234,7 +236,12 @@ export class FileOperations { `Displacing existing file at ${path} to '${conflictPath}' to make room` ); - this.expectedFsEvents.expectRename(path, conflictPath); + // Intentionally NOT calling `expectRename` here: the displaced + // file may be a tracked document (its `queue.documents` entry + // still points at `path`), and we need the watcher's + // `syncLocallyUpdatedFile` to flow into `queue.enqueue`'s + // path-update branch so the doc's map key follows its file + // to `conflictPath` and gets resynced await this.fs.rename(path, conflictPath); return path; } @@ -252,7 +259,7 @@ export class FileOperations { let directory = path; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { - [directory] = FileOperations.getParentDirAndFile(directory); + [directory] = FileOperations.getParentDirAndFileName(directory); if (directory.length === 0) { break; } diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 00dbd245..99a0221f 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -435,7 +435,18 @@ export class SyncClient { } public async waitUntilFinished(): Promise { - this.checkIfDestroyed("waitUntilIdle"); + this.checkIfDestroyed("waitUntilFinished"); + await this.waitUntilFinishedInternal(); + } + + /** + * The actual drain — separated from `waitUntilFinished` so internal + * shutdown paths (`pause` / `destroy`) can wait for in-flight work + * without tripping the public `checkIfDestroyed` guard, which exists + * only to keep external callers from continuing to use a disposed + * client. + */ + private async waitUntilFinishedInternal(): Promise { await this.syncer.waitUntilFinished(); await this.webSocketManager.waitUntilFinished(); await this.syncEventQueue.save(); @@ -502,7 +513,7 @@ export class SyncClient { // the rest of the client is winding down. this.syncService.stop(); await this.webSocketManager.stop(); - await this.waitUntilFinished(); + await this.waitUntilFinishedInternal(); // Clear the offline-scan gate so a subsequent `startSyncing()` // re-runs the scan; otherwise any local changes made while sync was // paused (offline edits, deletes, renames) wouldn't be detected, and diff --git a/frontend/sync-client/src/sync-operations/offline-change-detector.ts b/frontend/sync-client/src/sync-operations/offline-change-detector.ts index 368e07ed..b3cb4dd1 100644 --- a/frontend/sync-client/src/sync-operations/offline-change-detector.ts +++ b/frontend/sync-client/src/sync-operations/offline-change-detector.ts @@ -73,17 +73,25 @@ export async function scheduleOfflineChanges( for (const path of locallyPossibleCreatedFiles) { if (renamedPaths.has(path)) continue; - logger.debug( + + logger.info( `File ${path} was created while offline, scheduling sync to create it` ); + enqueueCreate(path); } for (const item of locallyPossiblyDeletedFiles) { + logger.info( + `File ${item.path} was deleted while offline, scheduling sync to delete it` + ); enqueueDelete(item.path); } for (const path of syncedLocalFiles) { + logger.info( + `File ${path} may have been updated while offline, scheduling sync to update it` + ); enqueueUpdate({ relativePath: path }); } } diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index ff8d9b65..d362b533 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -38,6 +38,14 @@ export class SyncEventQueue { // It maps pending changes onto the local filesystem. private readonly events: SyncEvent[] = []; + // Tombstones: documents we deleted along with the vaultUpdateId at + // which the delete committed. After we delete, the server may still + // send us older broadcasts for that document (e.g. a backlog update + // committed before the delete from another client). Without these + // entries, the syncer would resurrect the doc by treating an old + // update as a brand-new create. + private readonly deletedDocuments = new Map(); + // file creations for paths matching any of these patterns are ignored // because the user explicitly told us to ignore them. private userIgnorePatterns: RegExp[]; @@ -121,15 +129,29 @@ export class SyncEventQueue { return; } - if (this.ignoreConflictPaths && CONFLICT_PATH_REGEX.test(path)) { - this.logger.info( - `Ignoring ${input.type} for ${path} as it is a conflict path` - ); + if (input.type === SyncEventType.RemoteChange) { + this.events.push(input); return; } - if (input.type === SyncEventType.RemoteChange) { - this.events.push(input); + // Drop bare LocalCreate events for conflict paths. Those are + // produced by the watcher when the syncer's own write to a + // displacement path slips past the `ExpectedFsEvents` filter + // (e.g. a sync race where the watcher fires before + // `expectCreate` was registered). Re-uploading them as new docs + // would invent duplicates on the server. The legitimate way a + // conflict-path doc enters the queue is via the displacement + // rename's `LocalUpdate` (with `oldPath`) — that branch is + // allowed through below so the tracked document's path follows + // its file. + if ( + this.ignoreConflictPaths && + CONFLICT_PATH_REGEX.test(path) && + input.type === SyncEventType.LocalCreate + ) { + this.logger.info( + `Ignoring local-create for ${path} as it is a conflict path` + ); return; } @@ -215,6 +237,31 @@ export class SyncEventQueue { return this.events.shift(); } + + /** + * Return the next event without removing it. Drain uses this so the + * event stays visible in the queue while it is being processed — + * critical for `findLatestCreateForPath` to update an in-flight + * `LocalCreate`'s path when a rename arrives mid-process. Also marks + * the event as in-flight so dedup checks in `enqueue` know not to + * fold a fresh content change into an event whose disk read already + * happened. + */ + public peekFront(): SyncEvent | undefined { + return this.events[0]; + } + + /** + * Remove a specific event after `peekFront`-based processing is done. + * Idempotent — safe to call when the event was already taken out by + * `resolveCreate` (which clears a same-path pending create that a + * remote-create handler just absorbed). + */ + public consumeEvent(event: SyncEvent): void { + removeFromArray(this.events, event); + } + + /** * Call once a create has been acknowledged by the server. */ @@ -255,6 +302,42 @@ export class SyncEventQueue { return this.save(); } + /** + * Mark a document as deleted at a given vault-update version. Used by + * the syncer after a successful local or remote delete so future + * obsolete broadcasts for that doc (older parents that arrive late) + * don't resurrect it as a brand-new create. + */ + public recordDeletion( + documentId: DocumentId, + deletedAtVaultUpdateId: VaultUpdateId + ): void { + const existing = this.deletedDocuments.get(documentId); + if (existing !== undefined && existing >= deletedAtVaultUpdateId) { + return; + } + this.deletedDocuments.set(documentId, deletedAtVaultUpdateId); + } + + /** + * Returns the vault-update version at which we last saw this document + * deleted, or `undefined` if we have no record of its deletion. + */ + public getDeletionVersion( + documentId: DocumentId + ): VaultUpdateId | undefined { + return this.deletedDocuments.get(documentId); + } + + /** + * Forget a doc's tombstone — used when a doc with the same id is + * re-introduced (e.g. via a remote create whose server-side state + * surpasses the previous delete). + */ + public clearDeletion(documentId: DocumentId): void { + this.deletedDocuments.delete(documentId); + } + public getDocumentByDocumentId( target: DocumentId ): DocumentWithPath | undefined { @@ -327,6 +410,8 @@ export class SyncEventQueue { ); } + + public async clearAllState(): Promise { this.clearPending(); this.documents.clear(); diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index dea53285..fd924c15 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -128,7 +128,7 @@ export class Syncer { this.runningScheduleSyncForOfflineChanges = this.internalScheduleSyncForOfflineChanges(); await this.runningScheduleSyncForOfflineChanges; - this.logger.info(`All local changes have been applied remotely`); + this.logger.info(`All local changes have been queued`); } catch (e) { if (e instanceof SyncResetError) { this.logger.info( @@ -192,9 +192,8 @@ export class Syncer { // queue re-enables conflict filtering when we're done. this.queue.setIgnoreConflictPaths(false); try { - while (this.drainPromise !== undefined) { - await this.drainPromise; - } + this.queue.clearPending(); // can't have conflicts between the offline scan and ongoing operations created during the preceeding pause + await scheduleOfflineChanges( this.logger, this.operations, @@ -226,8 +225,21 @@ export class Syncer { } private async drain(): Promise { - let event = await this.queue.next(); - while (event !== undefined) { + // Peek then remove-after-processing (instead of shift-then-process): + // the event must remain reachable through `findLatestCreateForPath` + // while it is in flight, so a rename event arriving mid-process can + // call `updatePendingCreatePath` to retarget this create's path. + while (true) { + if (!this.settings.getSettings().isSyncEnabled) { + this.logger.debug( + "Drain pausing because sync is disabled; events stay queued" + ); + return; + } + const event = this.queue.peekFront(); + + if (event === undefined) { break; } + try { await this.processEvent(event); } catch (e) { @@ -239,19 +251,12 @@ export class Syncer { `Failed to process sync event ${event.type}: ${e}` ); } + this.queue.consumeEvent(event); this.notifyRemainingOperationsChanged(); - event = await this.queue.next(); } } private async processEvent(event: SyncEvent): Promise { - if (!this.settings.getSettings().isSyncEnabled) { - this.logger.info( - `Skipping sync operation because sync is disabled` - ); - return; - } - try { if (await this.skipIfOversized(event)) { return; @@ -272,22 +277,27 @@ export class Syncer { break; } } catch (e) { - // The currently-processed event was already shifted off the queue - // by drain() before processEvent ran. If it's a LocalCreate, any - // queued Delete/Update events whose `documentId` is this Create's - // resolvers.promise would `await` it forever once we return — so - // settle the resolvers on every failure path before - // dispatching/re-throwing. clearPending()'s rejectAllPendingCreates - // walks the queue and so cannot reach this in-flight event. - // Re-rejecting an already-resolved promise is a no-op, so it's - // safe to call this unconditionally on the LocalCreate branch. - if (event.type === SyncEventType.LocalCreate) { + // If a LocalCreate fails terminally, queued LocalDelete / + // LocalUpdate events whose `documentId` is this Create's + // `resolvers.promise` would `await` it forever — reject the + // resolver so they fail-fast with the same error class and + // hit their matching skip/log branch below. + // + // Only do this for terminal errors. `SyncResetError` is + // transient: drain returns without consuming the event, so + // the next drain retries the same Create. Rejecting the + // resolver now would permanently poison it, and the eventual + // `resolveCreate(...resolve)` after the retry succeeds is a + // no-op on an already-settled promise — leaving every + // dependent event stuck failing on `await event.documentId`. + if ( + event.type === SyncEventType.LocalCreate && + !(e instanceof SyncResetError) + ) { event.resolvers.promise.catch(() => { /* suppressed */ }); - event.resolvers.reject( - new Error(`Create was cancelled: ${e}`) - ); + event.resolvers.reject(e); } if (e instanceof FileNotFoundError) { @@ -364,8 +374,7 @@ export class Syncer { private async processCreate( event: Extract ): Promise { - const effectivePath = event.path; - const contentBytes = await this.operations.read(effectivePath); + const contentBytes = await this.operations.read(event.path); const contentHash = await hash(contentBytes); const response = await this.syncService.create({ @@ -375,7 +384,7 @@ export class Syncer { }); await this.handleMaybeMergingResponse({ - path: effectivePath, + path: event.path, response, contentHash, originalContentBytes: contentBytes, @@ -384,7 +393,7 @@ export class Syncer { this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, - details: { type: SyncType.CREATE, relativePath: effectivePath }, + details: { type: SyncType.CREATE, relativePath: event.path }, message: response.type === "MergingUpdate" ? "Created file and merged with existing remote version" @@ -399,7 +408,15 @@ export class Syncer { ): Promise { const documentId = await event.documentId; - const doc = this.queue.getDocumentByDocumentIdOrFail(documentId); + const doc = this.queue.getDocumentByDocumentId(documentId); + if (doc === undefined) { + // Already deleted (e.g. a remote delete drained ahead of + // this redundant local one). Nothing to do. + this.logger.debug( + `Skipping local-delete for ${documentId} — doc no longer tracked` + ); + return; + } const relativePath = doc.path; const response = await this.syncService.delete({ @@ -408,6 +425,7 @@ export class Syncer { }); await this.queue.removeDocument(doc.path); + this.queue.recordDeletion(documentId, response.vaultUpdateId); this.queue.lastSeenUpdateId = response.vaultUpdateId; this.history.addHistoryEntry({ @@ -426,8 +444,17 @@ export class Syncer { ): Promise { const documentId = await event.documentId; - const { path: diskPath, record } = - this.queue.getDocumentByDocumentIdOrFail(documentId); + const tracked = this.queue.getDocumentByDocumentId(documentId); + if (tracked === undefined) { + // The doc was deleted between this event being queued and + // drained — skip silently. Common when a LocalDelete drains + // ahead of a LocalUpdate that was already in the queue. + this.logger.debug( + `Skipping local-update for ${documentId} — doc no longer tracked (deleted)` + ); + return; + } + const { path: diskPath, record } = tracked; const contentBytes = await this.operations.read(diskPath); const contentHash = await hash(contentBytes); @@ -518,18 +545,50 @@ export class Syncer { } if (createEvent === undefined) { - // a http response will always be more up-to-date than any queued remote update - // move will always move to the relative path when MoveOnConflict.EXISTING is given - await this.operations.move( - path, - response.relativePath, - MoveOnConflict.EXISTING + // The disk path captured at the start of `processLocalUpdate` + // can be stale: the user may have renamed the file during the + // server roundtrip, in which case `queue.documents` already + // points at the new path and a follow-up rename's LocalUpdate + // is queued behind us. If we forced the disk back to + // `response.relativePath` here we'd undo the user's intent; + // worse, `setDocument`'s same-docId cleanup would clobber the + // map entry that was tracking the latest disk path, leaving + // future LocalUpdates for this doc reading from a vacated + // slot and getting skipped as `FileNotFoundError`. Refresh + // the latest tracked path and only touch disk when it still + // matches the captured one. + const tracked = this.queue.getDocumentByDocumentId( + response.documentId ); + if (tracked === undefined) { + this.logger.debug( + `Document ${response.documentId} is no longer tracked after update; cannot reconcile potential rename` + ); + } else { + const currentPath = tracked.path ?? path; + if (currentPath === path) { + // a http response will always be more up-to-date than any queued remote update + // move will always move to the relative path when MoveOnConflict.EXISTING is given + await this.operations.move( + path, + response.relativePath, + MoveOnConflict.EXISTING + ); - await this.queue.setDocument(response.relativePath, { - ...record, - remoteHash - }); + await this.queue.setDocument(response.relativePath, { + ...record, + remoteHash + }); + } else { + // User renamed during the roundtrip. Leave the disk file + // at `currentPath`; the queued rename's LocalUpdate will + // reconcile the server on its next drain. + await this.queue.setDocument(currentPath, { + ...record, + remoteHash + }); + } + } } else { // The server may have deconflicted the path on create (e.g. // another client raced us to the same path and won). Move the @@ -574,6 +633,24 @@ export class Syncer { ); } + // The doc was deleted at-or-after the version this broadcast + // describes (e.g. another client's update committed before our + // local delete; the server's backlog is replaying it now). Apply + // would resurrect a doc we deliberately removed. + const deletedAt = this.queue.getDeletionVersion( + remoteVersion.documentId + ); + if ( + deletedAt !== undefined && + deletedAt >= remoteVersion.vaultUpdateId + ) { + this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; + this.logger.debug( + `Skipping obsolete remote update for already-deleted document ${remoteVersion.documentId} (V=${remoteVersion.vaultUpdateId} <= deleted V=${deletedAt})` + ); + return; + } + if ( (documentWithPath?.record.parentVersionId ?? 0) >= remoteVersion.vaultUpdateId @@ -598,14 +675,7 @@ export class Syncer { remoteVersion.relativePath ); - if (pendingCreate === undefined) { - return this.processRemoteCreateForNewDocument(remoteVersion); - } else { - return this.processRemoteCreateForPendingDocument( - remoteVersion, - pendingCreate - ); - } + return this.processRemoteCreateForNewDocument(remoteVersion); } private async processRemoteDelete( @@ -614,6 +684,10 @@ export class Syncer { ): Promise { await this.operations.delete(path); await this.queue.removeDocument(path); + this.queue.recordDeletion( + remoteVersion.documentId, + remoteVersion.vaultUpdateId + ); this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; @@ -770,53 +844,7 @@ export class Syncer { }); } - // A remote create landed at a path where we have an unsynced local - // create. This might be becuase there's another sync client running. - // We must avoid duplicating files. - private async processRemoteCreateForPendingDocument( - remoteVersion: DocumentVersionWithoutContent, - pendingCreateEvent: Extract< - SyncEvent, - { type: SyncEventType.LocalCreate } - > - ): Promise { - const remoteContent = await this.syncService.getDocumentVersionContent({ - documentId: remoteVersion.documentId, - vaultUpdateId: remoteVersion.vaultUpdateId - }); - const remoteHash = await hash(remoteContent); - const path = remoteVersion.relativePath; - const currentContent = await this.operations.read( - pendingCreateEvent.path - ); - - await this.operations.write(path, currentContent, remoteContent); - await this.updateCache( - remoteVersion.vaultUpdateId, - remoteContent, - path - ); - - await this.queue.resolveCreate(pendingCreateEvent, { - documentId: remoteVersion.documentId, - parentVersionId: remoteVersion.vaultUpdateId, - remoteHash, - remoteRelativePath: path - }); - this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.UPDATE, - relativePath: path - }, - message: `Adopted remote create at ${path}`, - author: remoteVersion.userId, - timestamp: new Date(remoteVersion.updatedDate) - }); - } private async sendUpdate({ record, diff --git a/sync-server/src/server/create_document.rs b/sync-server/src/server/create_document.rs index 84703139..8230ea46 100644 --- a/sync-server/src/server/create_document.rs +++ b/sync-server/src/server/create_document.rs @@ -73,7 +73,10 @@ pub async fn create_document( // but client 2 moves it to P2 while client 1 creates a new document at P2, // then client 1 would merge its new document with the moved version of A at P2 // that client 2 resulting in two files (P1 and P2) with the same doc id (A). - if latest_version.creation_vault_update_id > request.last_seen_vault_update_id { + if latest_version.creation_vault_update_id > request.last_seen_vault_update_id + && latest_version.creation_vault_update_id == latest_version.vault_update_id + // can't allow merging with a moved document as that could create a cycle + { let is_mergeable_text = is_file_type_mergable( &sanitized_relative_path, &state.config.server.mergeable_file_extensions, From 8b7be48522da6b7673fad4a9db6a0794978706f0 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 26 Apr 2026 12:46:12 +0100 Subject: [PATCH 29/52] Linting --- .../src/deterministic-agent.ts | 24 ++++++++++++------- .../src/parse-concurrency.ts | 2 +- .../deterministic-tests/src/server-manager.ts | 2 +- frontend/eslint.config.mjs | 1 + .../src/views/cursors/file-explorer.ts | 2 +- .../sync-client/src/services/sync-service.ts | 2 +- .../src/sync-operations/conflict-path.ts | 4 ++-- .../src/sync-operations/expected-fs-events.ts | 6 ++--- .../offline-change-detector.ts | 2 +- .../src/sync-operations/sync-event-queue.ts | 4 ++-- .../sync-client/src/sync-operations/syncer.ts | 8 +++---- .../utils/data-structures/event-listeners.ts | 4 ++-- frontend/test-client/src/agent/mock-agent.ts | 4 ++-- 13 files changed, 37 insertions(+), 28 deletions(-) diff --git a/frontend/deterministic-tests/src/deterministic-agent.ts b/frontend/deterministic-tests/src/deterministic-agent.ts index f253186a..da1435b0 100644 --- a/frontend/deterministic-tests/src/deterministic-agent.ts +++ b/frontend/deterministic-tests/src/deterministic-agent.ts @@ -192,6 +192,10 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { const isNew = !this.files.has(path); await super.write(path, content); + if (!this.isSyncEnabled) { + return; + } + if (isNew) { this.enqueueSync(async () => { this.client.syncLocallyCreatedFile(path); @@ -208,9 +212,11 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { updater: (current: TextWithCursors) => TextWithCursors ): Promise { const result = await super.atomicUpdateText(path, updater); - this.enqueueSync(async () => { - this.client.syncLocallyUpdatedFile({ relativePath: path }); - }); + if (this.isSyncEnabled) { + this.enqueueSync(async () => { + this.client.syncLocallyUpdatedFile({ relativePath: path }); + }); + } return result; } @@ -228,12 +234,14 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { newPath: RelativePath ): Promise { await super.rename(oldPath, newPath); - this.enqueueSync(async () => { - this.client.syncLocallyUpdatedFile({ - oldPath, - relativePath: newPath + if (this.isSyncEnabled) { + this.enqueueSync(async () => { + this.client.syncLocallyUpdatedFile({ + oldPath, + relativePath: newPath + }); }); - }); + } } private async waitForWebSocket(): Promise { diff --git a/frontend/deterministic-tests/src/parse-concurrency.ts b/frontend/deterministic-tests/src/parse-concurrency.ts index a6622a04..3250f7cf 100644 --- a/frontend/deterministic-tests/src/parse-concurrency.ts +++ b/frontend/deterministic-tests/src/parse-concurrency.ts @@ -8,7 +8,7 @@ export function parseConcurrency(): number { i + 1 < args.length ) { const n = parseInt(args[i + 1], 10); - if (!isNaN(n) && n > 0) return n; + if (!isNaN(n) && n > 0) {return n;} } } return os.cpus().length; diff --git a/frontend/deterministic-tests/src/server-manager.ts b/frontend/deterministic-tests/src/server-manager.ts index e9ca3d57..68174c56 100644 --- a/frontend/deterministic-tests/src/server-manager.ts +++ b/frontend/deterministic-tests/src/server-manager.ts @@ -19,7 +19,7 @@ export class ServerManager { } public async stopAll(): Promise { - if (this.isShuttingDown) return; + if (this.isShuttingDown) {return;} this.isShuttingDown = true; const servers = Array.from(this.activeServers); diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 61a8bade..eed30760 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -19,6 +19,7 @@ export default [ rules: { "no-console": "error", "no-unused-vars": "off", + "curly": ["error", "all"], "@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-floating-promises": [ diff --git a/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts b/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts index fa8e0803..cddad54d 100644 --- a/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts +++ b/frontend/obsidian-plugin/src/views/cursors/file-explorer.ts @@ -14,7 +14,7 @@ export function renderCursorsInFileExplorer( app: App ): void { const fileExplorers = app.workspace.getLeavesOfType("file-explorer"); - if (fileExplorers.length == 0) return; + if (fileExplorers.length == 0) {return;} const [fileExplorer] = fileExplorers; diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index 228cc2f2..faada477 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -70,7 +70,7 @@ export class SyncService { response: Response, operation: string ): Promise { - if (response.ok) return; + if (response.ok) {return;} const message = `Failed to ${operation}: ${await SyncService.errorFromResponse(response)}`; // 429 is the only 4xx the server uses for *transient* contention // (`WriteBusyError` → HTTP 429). Every other 4xx means the request diff --git a/frontend/sync-client/src/sync-operations/conflict-path.ts b/frontend/sync-client/src/sync-operations/conflict-path.ts index adc1bea1..84efbfe2 100644 --- a/frontend/sync-client/src/sync-operations/conflict-path.ts +++ b/frontend/sync-client/src/sync-operations/conflict-path.ts @@ -17,7 +17,7 @@ function truncateFileNameToByteLimit( maxBytes: number ): string { const encoder = new TextEncoder(); - if (encoder.encode(fileName).byteLength <= maxBytes) return fileName; + if (encoder.encode(fileName).byteLength <= maxBytes) {return fileName;} const dotIndex = fileName.lastIndexOf("."); // Dotfile (starts with "." and nothing else) → no extension to preserve. @@ -35,7 +35,7 @@ function truncateFileNameToByteLimit( let usedBytes = 0; for (const { segment } of segmenter.segment(stem)) { const segmentBytes = encoder.encode(segment).byteLength; - if (usedBytes + segmentBytes > stemBudget) break; + if (usedBytes + segmentBytes > stemBudget) {break;} truncatedStem += segment; usedBytes += segmentBytes; } diff --git a/frontend/sync-client/src/sync-operations/expected-fs-events.ts b/frontend/sync-client/src/sync-operations/expected-fs-events.ts index 01d90b79..22c229e7 100644 --- a/frontend/sync-client/src/sync-operations/expected-fs-events.ts +++ b/frontend/sync-client/src/sync-operations/expected-fs-events.ts @@ -90,9 +90,9 @@ export class ExpectedFsEvents { key: RelativePath ): boolean { const count = map.get(key) ?? 0; - if (count === 0) return false; - if (count === 1) map.delete(key); - else map.set(key, count - 1); + if (count === 0) {return false;} + if (count === 1) {map.delete(key);} + else {map.set(key, count - 1);} return true; } } diff --git a/frontend/sync-client/src/sync-operations/offline-change-detector.ts b/frontend/sync-client/src/sync-operations/offline-change-detector.ts index b3cb4dd1..534e35c5 100644 --- a/frontend/sync-client/src/sync-operations/offline-change-detector.ts +++ b/frontend/sync-client/src/sync-operations/offline-change-detector.ts @@ -72,7 +72,7 @@ export async function scheduleOfflineChanges( } for (const path of locallyPossibleCreatedFiles) { - if (renamedPaths.has(path)) continue; + if (renamedPaths.has(path)) {continue;} logger.info( `File ${path} was created while offline, scheduling sync to create it` diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index d362b533..c841fc0b 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -17,7 +17,7 @@ import { import { MinCovered } from "../utils/data-structures/min-covered"; export class SyncEventQueue { - private _lastSeenUpdateId: MinCovered; + private readonly _lastSeenUpdateId: MinCovered; // Latest state of the filesystem as we know it, excluding // unconfirmed creates but including pending deletes. @@ -441,7 +441,7 @@ export class SyncEventQueue { newPath: RelativePath ): void { const createEvent = this.findLatestCreateForPath(oldPath); - if (createEvent === undefined) return; + if (createEvent === undefined) {return;} const { promise } = createEvent.resolvers; createEvent.path = newPath; diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index fd924c15..fffa0300 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -217,8 +217,8 @@ export class Syncer { } private ensureDraining(): void { - if (this.drainPromise !== undefined) return; - if (this.isScanning) return; + if (this.drainPromise !== undefined) {return;} + if (this.isScanning) {return;} this.drainPromise = this.drain().finally(() => { this.drainPromise = undefined; }); @@ -329,7 +329,7 @@ export class Syncer { relativePath = event.path; break; case SyncEventType.RemoteChange: - if (event.remoteVersion.isDeleted) return false; + if (event.remoteVersion.isDeleted) {return false;} sizeInBytes = event.remoteVersion.contentSize; ({ relativePath } = event.remoteVersion); break; @@ -339,7 +339,7 @@ export class Syncer { sizeInBytes, relativePath ); - if (oversizedEntry === undefined) return false; + if (oversizedEntry === undefined) {return false;} this.history.addHistoryEntry(oversizedEntry); diff --git a/frontend/sync-client/src/utils/data-structures/event-listeners.ts b/frontend/sync-client/src/utils/data-structures/event-listeners.ts index 47c8b8ee..2bac9904 100644 --- a/frontend/sync-client/src/utils/data-structures/event-listeners.ts +++ b/frontend/sync-client/src/utils/data-structures/event-listeners.ts @@ -43,7 +43,7 @@ export class EventListeners any> { const snapshot = this.listeners.slice(); for (const listener of snapshot) { // allow removing listeners during the trigger loop - if (!this.listeners.includes(listener)) continue; + if (!this.listeners.includes(listener)) {continue;} listener(...args); } } @@ -59,7 +59,7 @@ export class EventListeners any> { const snapshot = this.listeners.slice(); const promises: Promise[] = []; for (const listener of snapshot) { - if (!this.listeners.includes(listener)) continue; + if (!this.listeners.includes(listener)) {continue;} // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const result = listener(...args); if (result instanceof Promise) { diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index 00acc600..786b7d9f 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -531,9 +531,9 @@ export class MockAgent extends MockClient { private removeBinaryUuid(file: string): void { const existing = this.files.get(file); - if (existing === undefined) return; + if (existing === undefined) {return;} const content = new TextDecoder().decode(existing); - if (!content.startsWith("BINARY:")) return; + if (!content.startsWith("BINARY:")) {return;} const uuid = content.slice("BINARY:".length); utils.removeFromArray(this.writtenBinaryContents, uuid); } From fc0ff0df1c0cabe932b6bfebf58dc25b547135c7 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 26 Apr 2026 12:46:46 +0100 Subject: [PATCH 30/52] Remove useless syncs --- .../src/tests/18-create-rename-create-same-path.test.ts | 1 - .../tests/4-coalesced-remote-update-watermark-loss.test.ts | 1 - .../src/tests/9-concurrent-rename-same-target.test.ts | 1 - .../src/tests/delete-during-pending-create.test.ts | 2 -- .../src/tests/delete-recreate-concurrent-update.test.ts | 2 -- .../src/tests/delete-recreate-different-content.test.ts | 2 -- .../src/tests/delete-recreate-same-path.test.ts | 2 -- .../src/tests/delete-rename-conflict.test.ts | 2 -- .../src/tests/double-offline-cycle.test.ts | 4 ---- .../src/tests/idempotency-after-server-pause.test.ts | 2 -- .../src/tests/interrupted-delete-retry.test.ts | 2 -- .../src/tests/key-migration-event-drop.test.ts | 2 -- .../src/tests/mc-cross-create-rename-same-target.test.ts | 2 -- .../src/tests/mc-delete-then-offline-rename.test.ts | 2 -- .../src/tests/mc-multi-delete-offline-rename.test.ts | 2 -- .../src/tests/mc-three-client-rename-offline-update.test.ts | 2 -- .../src/tests/migrate-key-preserves-existing.test.ts | 2 -- .../src/tests/move-and-concurrent-remote-update.test.ts | 2 -- .../src/tests/move-preserves-remote-update.test.ts | 2 -- .../src/tests/move-remote-update-reverts-rename.test.ts | 2 -- .../src/tests/move-then-delete-stale-path.test.ts | 2 -- .../src/tests/multi-file-operations.test.ts | 2 -- .../src/tests/offline-concurrent-renames.test.ts | 2 -- .../tests/offline-create-same-path-binary-conflict.test.ts | 1 - .../src/tests/offline-delete-vs-remote-update.test.ts | 2 -- .../src/tests/offline-edit-remote-rename.test.ts | 2 -- .../src/tests/offline-edit-then-move-same-content.test.ts | 2 -- .../src/tests/offline-mixed-operations.test.ts | 2 -- .../src/tests/offline-move-then-remote-delete.test.ts | 2 -- .../src/tests/offline-multiple-edits.test.ts | 2 -- .../src/tests/offline-rename-and-edit.test.ts | 2 -- .../src/tests/offline-rename-remote-create-old-path.test.ts | 2 -- .../src/tests/offline-update-both-then-delete-one.test.ts | 2 -- .../src/tests/overlapping-edits-same-section.test.ts | 2 -- .../src/tests/rapid-create-update-delete-cycle.test.ts | 1 - .../src/tests/rapid-updates-after-merge.test.ts | 2 -- .../src/tests/rename-chain-then-delete.test.ts | 2 -- frontend/deterministic-tests/src/tests/rename-chain.test.ts | 1 - .../deterministic-tests/src/tests/rename-circular.test.ts | 1 - .../src/tests/rename-create-conflict.test.ts | 1 - .../deterministic-tests/src/tests/rename-roundtrip.test.ts | 3 --- .../src/tests/rename-to-pending-path-fallback.test.ts | 2 -- .../src/tests/rename-to-recently-deleted-path.test.ts | 2 -- .../src/tests/rename-update-conflict.test.ts | 2 -- .../src/tests/sequential-create-duplicate-content.test.ts | 2 -- .../src/tests/server-pause-both-clients-create.test.ts | 2 -- .../src/tests/server-pause-both-edit-same-file.test.ts | 3 --- .../src/tests/server-pause-rename-edit-resume.test.ts | 2 -- .../src/tests/simultaneous-create-delete-same-path.test.ts | 2 -- .../src/tests/update-during-create-processing.test.ts | 2 -- .../src/tests/watermark-advances-on-skip.test.ts | 3 --- .../tests/watermark-gap-remote-update-not-recorded.test.ts | 4 ---- 52 files changed, 103 deletions(-) diff --git a/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts b/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts index dda80042..b9e16c90 100644 --- a/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts @@ -18,7 +18,6 @@ export const createRenameCreateSamePathTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts b/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts index 8b1cd242..aceb8baa 100644 --- a/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts +++ b/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts @@ -16,7 +16,6 @@ export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = { { type: "update", client: 0, path: "doc.md", content: "update 1" }, { type: "update", client: 0, path: "doc.md", content: "update 2" }, { type: "update", client: 0, path: "doc.md", content: "final update" }, - { type: "sync", client: 0 }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts index eff10952..0b72c0f3 100644 --- a/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts +++ b/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts @@ -21,7 +21,6 @@ export const concurrentRenameSameTargetTest: TestDefinition = { { type: "rename", client: 1, oldPath: "B.md", newPath: "C.md" }, { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts b/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts index 831c2f05..3ba393b8 100644 --- a/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-during-pending-create.test.ts @@ -9,7 +9,6 @@ export const deleteDuringPendingCreateTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "pause-server" }, @@ -24,7 +23,6 @@ export const deleteDuringPendingCreateTest: TestDefinition = { { type: "delete", client: 0, path: "ephemeral.md" }, { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts index 0d4bcffb..6cb4cb98 100644 --- a/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-recreate-concurrent-update.test.ts @@ -10,7 +10,6 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = { { type: "create", client: 0, path: "A.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -31,7 +30,6 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = { { type: "sync", client: 1 }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts index 7ecd21a3..782c3cd5 100644 --- a/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts @@ -15,7 +15,6 @@ export const deleteRecreateDifferentContentTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -39,7 +38,6 @@ export const deleteRecreateDifferentContentTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts index 4b2a836b..dde8d341 100644 --- a/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-recreate-same-path.test.ts @@ -10,7 +10,6 @@ export const deleteRecreateSamePathTest: TestDefinition = { { type: "create", client: 0, path: "A.md", content: "version 1" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -23,7 +22,6 @@ export const deleteRecreateSamePathTest: TestDefinition = { { type: "delete", client: 0, path: "A.md" }, { type: "create", client: 0, path: "A.md", content: "version 2" }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts b/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts index 7eeb80ad..91e6289b 100644 --- a/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-rename-conflict.test.ts @@ -11,7 +11,6 @@ export const deleteRenameConflictTest: TestDefinition = { { type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -28,7 +27,6 @@ export const deleteRenameConflictTest: TestDefinition = { { type: "rename", client: 1, oldPath: "A.md", newPath: "C.md" }, { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts b/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts index f617ca5f..744d862e 100644 --- a/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts +++ b/frontend/deterministic-tests/src/tests/double-offline-cycle.test.ts @@ -15,7 +15,6 @@ export const doubleOfflineCycleTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -33,7 +32,6 @@ export const doubleOfflineCycleTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -51,7 +49,6 @@ export const doubleOfflineCycleTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -69,7 +66,6 @@ export const doubleOfflineCycleTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", diff --git a/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts b/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts index 58c57511..551c702d 100644 --- a/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts +++ b/frontend/deterministic-tests/src/tests/idempotency-after-server-pause.test.ts @@ -9,7 +9,6 @@ export const idempotencyAfterServerPauseTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { @@ -22,7 +21,6 @@ export const idempotencyAfterServerPauseTest: TestDefinition = { { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts index 444adc56..3ae7eda5 100644 --- a/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts +++ b/frontend/deterministic-tests/src/tests/interrupted-delete-retry.test.ts @@ -10,7 +10,6 @@ export const interruptedDeleteRetryTest: TestDefinition = { { type: "create", client: 0, path: "doc.md", content: "to be deleted" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "delete", client: 0, path: "doc.md" }, @@ -18,7 +17,6 @@ export const interruptedDeleteRetryTest: TestDefinition = { { type: "pause-server" }, { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts b/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts index f29fa45b..cc40e6b0 100644 --- a/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts +++ b/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts @@ -9,7 +9,6 @@ export const keyMigrationEventDropTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "pause-server" }, @@ -28,7 +27,6 @@ export const keyMigrationEventDropTest: TestDefinition = { }, { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts index b0175b37..d986a733 100644 --- a/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-cross-create-rename-same-target.test.ts @@ -13,7 +13,6 @@ export const mcCrossCreateRenameSameTargetTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { @@ -31,7 +30,6 @@ export const mcCrossCreateRenameSameTargetTest: TestDefinition = { { type: "rename", client: 1, oldPath: "Y.md", newPath: "Z.md" }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts b/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts index 0808c65a..6727e99d 100644 --- a/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-delete-then-offline-rename.test.ts @@ -12,7 +12,6 @@ export const mcDeleteThenOfflineRenameTest: TestDefinition = { { type: "create", client: 0, path: "C.md", content: "unrelated" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 1 }, @@ -23,7 +22,6 @@ export const mcDeleteThenOfflineRenameTest: TestDefinition = { { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts b/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts index 1dbb3464..8db90aab 100644 --- a/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-multi-delete-offline-rename.test.ts @@ -14,7 +14,6 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = { { type: "create", client: 0, path: "file-5.md", content: "content-5" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -31,7 +30,6 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts b/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts index 3ab451e2..4167b925 100644 --- a/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts +++ b/frontend/deterministic-tests/src/tests/mc-three-client-rename-offline-update.test.ts @@ -11,7 +11,6 @@ export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "enable-sync", client: 2 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 2 }, @@ -28,7 +27,6 @@ export const mcThreeClientRenameOfflineUpdateTest: TestDefinition = { }, { type: "enable-sync", client: 2 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts b/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts index a230df24..bb669e45 100644 --- a/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts +++ b/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts @@ -9,7 +9,6 @@ export const migrateKeyPreservesExistingTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "pause-server" }, @@ -23,7 +22,6 @@ export const migrateKeyPreservesExistingTest: TestDefinition = { }, { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts b/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts index c1453390..86657f0f 100644 --- a/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/move-and-concurrent-remote-update.test.ts @@ -15,7 +15,6 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -30,7 +29,6 @@ export const moveAndConcurrentRemoteUpdateTest: TestDefinition = { { type: "sync", client: 1 }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts b/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts index aae5f18c..13e27349 100644 --- a/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/move-preserves-remote-update.test.ts @@ -15,7 +15,6 @@ export const movePreservesRemoteUpdateTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -31,7 +30,6 @@ export const movePreservesRemoteUpdateTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts b/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts index 29e3fd27..4fe6f9cb 100644 --- a/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/move-remote-update-reverts-rename.test.ts @@ -10,7 +10,6 @@ export const moveRemoteUpdateRevertsRenameTest: TestDefinition = { { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -24,7 +23,6 @@ export const moveRemoteUpdateRevertsRenameTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "rename", client: 0, oldPath: "doc.md", newPath: "renamed.md" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts b/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts index dbbec7af..4f5feab5 100644 --- a/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts +++ b/frontend/deterministic-tests/src/tests/move-then-delete-stale-path.test.ts @@ -15,13 +15,11 @@ export const moveThenDeleteStalePathTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, { type: "delete", client: 0, path: "B.md" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts index b241433d..a47f5a2a 100644 --- a/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts +++ b/frontend/deterministic-tests/src/tests/multi-file-operations.test.ts @@ -12,7 +12,6 @@ export const multiFileOperationsTest: TestDefinition = { { type: "create", client: 0, path: "C.md", content: "content-c" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 1 }, @@ -29,7 +28,6 @@ export const multiFileOperationsTest: TestDefinition = { { type: "rename", client: 1, oldPath: "A.md", newPath: "D.md" }, { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts index ff16608b..6c946b9c 100644 --- a/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-concurrent-renames.test.ts @@ -12,7 +12,6 @@ export const offlineConcurrentRenamesTest: TestDefinition = { { type: "create", client: 0, path: "A.md", content: "shared-content" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -40,7 +39,6 @@ export const offlineConcurrentRenamesTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts b/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts index 9a4939ef..cbd59a4a 100644 --- a/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts @@ -23,7 +23,6 @@ export const offlineCreateSamePathMergeableTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts b/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts index 73db9efa..21e81aa6 100644 --- a/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-delete-vs-remote-update.test.ts @@ -14,7 +14,6 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -35,7 +34,6 @@ export const offlineDeleteVsRemoteUpdateTest: TestDefinition = { { type: "sync", client: 1 }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts index 0d6c0be5..ffc41b89 100644 --- a/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-edit-remote-rename.test.ts @@ -10,7 +10,6 @@ export const offlineEditRemoteRenameTest: TestDefinition = { { type: "create", client: 0, path: "A.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -36,7 +35,6 @@ export const offlineEditRemoteRenameTest: TestDefinition = { { type: "sync", client: 1 }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts b/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts index 074874a8..970eabd3 100644 --- a/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-edit-then-move-same-content.test.ts @@ -20,7 +20,6 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -37,7 +36,6 @@ export const offlineEditThenMoveSameContentTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts b/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts index 06f890d1..da875b6e 100644 --- a/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-mixed-operations.test.ts @@ -13,7 +13,6 @@ export const offlineMixedOperationsTest: TestDefinition = { { type: "create", client: 0, path: "file3.md", content: "content-3" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { @@ -42,7 +41,6 @@ export const offlineMixedOperationsTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts index 1ded0e6e..f20211b4 100644 --- a/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts @@ -15,7 +15,6 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -25,7 +24,6 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = { { type: "sync", client: 1 }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts b/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts index 08aed64d..6341fe8f 100644 --- a/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-multiple-edits.test.ts @@ -11,7 +11,6 @@ export const offlineMultipleEditsTest: TestDefinition = { { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -29,7 +28,6 @@ export const offlineMultipleEditsTest: TestDefinition = { { type: "update", client: 0, path: "doc.md", content: "edit-5-final" }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts index 0cc02c88..836c7fb2 100644 --- a/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-rename-and-edit.test.ts @@ -11,7 +11,6 @@ export const offlineRenameAndEditTest: TestDefinition = { { type: "create", client: 0, path: "A.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -30,7 +29,6 @@ export const offlineRenameAndEditTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts b/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts index b20061f6..c1b2913a 100644 --- a/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-rename-remote-create-old-path.test.ts @@ -11,7 +11,6 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { { type: "create", client: 0, path: "X.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -37,7 +36,6 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = { { type: "sync", client: 1 }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts b/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts index 3019f2ae..3442cda7 100644 --- a/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-update-both-then-delete-one.test.ts @@ -23,7 +23,6 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -61,7 +60,6 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = { { type: "sync", client: 1 }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts b/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts index 14b013d6..a93a6f69 100644 --- a/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts +++ b/frontend/deterministic-tests/src/tests/overlapping-edits-same-section.test.ts @@ -15,7 +15,6 @@ export const overlappingEditsSameSectionTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -37,7 +36,6 @@ export const overlappingEditsSameSectionTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts index db9ed848..f9c58753 100644 --- a/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts +++ b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts @@ -9,7 +9,6 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "pause-server" }, diff --git a/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts b/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts index 1a155814..6f97ff05 100644 --- a/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts +++ b/frontend/deterministic-tests/src/tests/rapid-updates-after-merge.test.ts @@ -12,7 +12,6 @@ export const rapidUpdatesAfterMergeTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { @@ -37,7 +36,6 @@ export const rapidUpdatesAfterMergeTest: TestDefinition = { path: "doc.md", content: "update 3" }, - { type: "sync", client: 0 }, { type: "barrier" }, diff --git a/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts index 97661f4f..03196919 100644 --- a/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-chain-then-delete.test.ts @@ -10,7 +10,6 @@ export const renameChainThenDeleteTest: TestDefinition = { { type: "create", client: 0, path: "X.md", content: "chain-content" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -39,7 +38,6 @@ export const renameChainThenDeleteTest: TestDefinition = { { type: "sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/rename-chain.test.ts b/frontend/deterministic-tests/src/tests/rename-chain.test.ts index 15365fc1..8f9d7a7f 100644 --- a/frontend/deterministic-tests/src/tests/rename-chain.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-chain.test.ts @@ -20,7 +20,6 @@ export const renameChainTest: TestDefinition = { { type: "rename", client: 0, oldPath: "B.md", newPath: "C.md" }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/rename-circular.test.ts b/frontend/deterministic-tests/src/tests/rename-circular.test.ts index 19ff899e..44a65149 100644 --- a/frontend/deterministic-tests/src/tests/rename-circular.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-circular.test.ts @@ -28,7 +28,6 @@ export const renameCircularTest: TestDefinition = { { type: "rename", client: 0, oldPath: "temp-a.md", newPath: "B.md" }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts index 635e6e91..816c2559 100644 --- a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts @@ -22,7 +22,6 @@ export const renameCreateConflictTest: TestDefinition = { { type: "sync", client: 1 }, { type: "create", client: 0, path: "B.md", content: "hi" }, { type: "enable-sync", client: 0 }, - { type: "sync", client: 0 }, { type: "barrier" }, { type: "assert-consistent", diff --git a/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts index 19a1240f..0373debf 100644 --- a/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-roundtrip.test.ts @@ -9,7 +9,6 @@ export const renameRoundtripTest: TestDefinition = { { type: "create", client: 0, path: "A.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -19,7 +18,6 @@ export const renameRoundtripTest: TestDefinition = { }, { type: "rename", client: 0, oldPath: "A.md", newPath: "B.md" }, - { type: "sync" }, { type: "barrier" }, { @@ -30,7 +28,6 @@ export const renameRoundtripTest: TestDefinition = { }, { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts b/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts index 1d65f9ee..8747218a 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-pending-path-fallback.test.ts @@ -14,7 +14,6 @@ export const renameToPendingPathFallbackTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -29,7 +28,6 @@ export const renameToPendingPathFallbackTest: TestDefinition = { { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, { type: "enable-sync", client: 0 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts b/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts index a7a8a9a5..474d2d42 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts @@ -10,7 +10,6 @@ export const renameToRecentlyDeletedPathTest: TestDefinition = { { type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 1 }, @@ -26,7 +25,6 @@ export const renameToRecentlyDeletedPathTest: TestDefinition = { }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts index 27cae589..18d4c101 100644 --- a/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-update-conflict.test.ts @@ -9,7 +9,6 @@ export const renameUpdateConflictTest: TestDefinition = { { type: "create", client: 0, path: "A.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -31,7 +30,6 @@ export const renameUpdateConflictTest: TestDefinition = { }, { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts b/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts index 0169fbba..611e1ae3 100644 --- a/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts +++ b/frontend/deterministic-tests/src/tests/sequential-create-duplicate-content.test.ts @@ -14,7 +14,6 @@ export const sequentialCreateDuplicateContentTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { @@ -30,7 +29,6 @@ export const sequentialCreateDuplicateContentTest: TestDefinition = { path: "B.md", content: "identical content here" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts b/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts index 359f1a36..f99cf92d 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-both-clients-create.test.ts @@ -8,7 +8,6 @@ export const serverPauseBothClientsCreateTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { @@ -28,7 +27,6 @@ export const serverPauseBothClientsCreateTest: TestDefinition = { { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts b/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts index e09c8e6c..ff8cf194 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-both-edit-same-file.test.ts @@ -14,7 +14,6 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "pause-server" }, @@ -35,7 +34,6 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { }, { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { @@ -55,7 +53,6 @@ export const serverPauseBothEditSameFileTest: TestDefinition = { path: "shared.md", content: "post-merge edit from client 0" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts b/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts index 2f378921..b1739135 100644 --- a/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts +++ b/frontend/deterministic-tests/src/tests/server-pause-rename-edit-resume.test.ts @@ -16,7 +16,6 @@ export const serverPauseRenameEditResumeTest: TestDefinition = { path: "A.md", content: "original content" }, - { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -37,7 +36,6 @@ export const serverPauseRenameEditResumeTest: TestDefinition = { { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts index c7f71165..8ccb0c8f 100644 --- a/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts @@ -11,7 +11,6 @@ export const simultaneousCreateDeleteSamePathTest: TestDefinition = { { type: "create", client: 0, path: "A.md", content: "original from 0" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 1 }, @@ -27,7 +26,6 @@ export const simultaneousCreateDeleteSamePathTest: TestDefinition = { }, { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts b/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts index 54c1beaf..ca53244e 100644 --- a/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts +++ b/frontend/deterministic-tests/src/tests/update-during-create-processing.test.ts @@ -8,7 +8,6 @@ export const updateDuringCreateProcessingTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "pause-server" }, @@ -28,7 +27,6 @@ export const updateDuringCreateProcessingTest: TestDefinition = { }, { type: "resume-server" }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts b/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts index 0212a19f..063faff4 100644 --- a/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts +++ b/frontend/deterministic-tests/src/tests/watermark-advances-on-skip.test.ts @@ -8,7 +8,6 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -18,14 +17,12 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts b/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts index 0ee606f0..ac9ba467 100644 --- a/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts +++ b/frontend/deterministic-tests/src/tests/watermark-gap-remote-update-not-recorded.test.ts @@ -9,15 +9,12 @@ export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = { { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { type: "update", client: 0, path: "doc.md", content: "update 1" }, { type: "sync", client: 0 }, { type: "update", client: 0, path: "doc.md", content: "update 2" }, - { type: "sync", client: 0 }, - { type: "sync", client: 1 }, { type: "barrier" }, { type: "assert-consistent", @@ -28,7 +25,6 @@ export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = { { type: "disable-sync", client: 1 }, { type: "enable-sync", client: 1 }, - { type: "sync" }, { type: "barrier" }, { From 2a6d824cc9b9b07e75976ec4dac1c802bf681c32 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 26 Apr 2026 13:08:10 +0100 Subject: [PATCH 31/52] Fix tests --- .../deterministic-tests/src/test-registry.ts | 2 - .../concurrent-rename-first-wins.test.ts | 5 +-- .../displaced-file-not-marked-deleted.test.ts | 16 ++------ .../rename-to-recently-deleted-path.test.ts | 39 ------------------- .../sync-client/src/services/server-config.ts | 2 +- .../src/sync-operations/sync-event-queue.ts | 2 +- 6 files changed, 8 insertions(+), 58 deletions(-) delete mode 100644 frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts diff --git a/frontend/deterministic-tests/src/test-registry.ts b/frontend/deterministic-tests/src/test-registry.ts index 3dd20ab0..1d004cda 100644 --- a/frontend/deterministic-tests/src/test-registry.ts +++ b/frontend/deterministic-tests/src/test-registry.ts @@ -25,7 +25,6 @@ import { offlineRenameRemoteCreateOldPathTest } from "./tests/offline-rename-rem import { offlineEditRemoteRenameTest } from "./tests/offline-edit-remote-rename.test"; import { renameChainThenDeleteTest } from "./tests/rename-chain-then-delete.test"; import { offlineDeleteRemoteRenameTest } from "./tests/offline-delete-remote-rename.test"; -import { renameToRecentlyDeletedPathTest } from "./tests/rename-to-recently-deleted-path.test"; import { overlappingEditsSameSectionTest } from "./tests/overlapping-edits-same-section.test"; import { rapidUpdatesAfterMergeTest } from "./tests/rapid-updates-after-merge.test"; import { deleteRecreateConcurrentUpdateTest } from "./tests/delete-recreate-concurrent-update.test"; @@ -121,7 +120,6 @@ export const TESTS: Partial> = { "offline-edit-remote-rename": offlineEditRemoteRenameTest, "rename-chain-then-delete": renameChainThenDeleteTest, "offline-delete-remote-rename": offlineDeleteRemoteRenameTest, - "rename-to-recently-deleted-path": renameToRecentlyDeletedPathTest, "overlapping-edits-same-section": overlappingEditsSameSectionTest, "rapid-updates-after-merge": rapidUpdatesAfterMergeTest, "delete-recreate-concurrent-update": deleteRecreateConcurrentUpdateTest, diff --git a/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts index aef7688d..18023a99 100644 --- a/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-first-wins.test.ts @@ -51,9 +51,8 @@ export const concurrentRenameFirstWinsTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertFileNotExists("A.md"); - s.assertFileCount(1); - s.assertAnyFileContains("edit from 0", "edit from 1"); + s.assertFileNotExists("A.md"). + assertFileCount(2).assertContent("B.md", "edit from 0\nline 2\nline 3").assertContent("C.md", "line 1\nline 2\nedit from 1"); } } ] diff --git a/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts b/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts index 326343af..cb995243 100644 --- a/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts +++ b/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts @@ -16,17 +16,11 @@ export const displacedFileNotMarkedDeletedTest: TestDefinition = { { type: "disable-sync", client: 1 }, - { type: "create", client: 0, path: "B.md", content: "new file B" }, + { type: "create", client: 0, path: "B.md", content: "content of 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" }, @@ -35,11 +29,9 @@ export const displacedFileNotMarkedDeletedTest: TestDefinition = { type: "assert-consistent", verify: (state: AssertableState): void => { state - .assertFileNotExists("A.md") - .assertFileExists("B.md") - .assertContains("B.md", "new file B") - .assertFileExists("C.md") - .assertContains("C.md", "edited A content"); + .assertFileCount(2) + .assertContent("B.md", "content of B") + .assertContent("C.md", "content of A"); } } ] diff --git a/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts b/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts deleted file mode 100644 index 474d2d42..00000000 --- a/frontend/deterministic-tests/src/tests/rename-to-recently-deleted-path.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { AssertableState } from "../utils/assertable-state"; -import type { TestDefinition } from "../test-definition"; - -export const renameToRecentlyDeletedPathTest: TestDefinition = { - description: - "Client 0 deletes B.md. Client 1 renames A.md to B.md offline. After reconnecting, only B.md should exist with A's content.", - clients: 2, - steps: [ - { 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: 1 }, - { type: "barrier" }, - - { type: "disable-sync", client: 1 }, - - { type: "delete", client: 0, path: "B.md" }, - { type: "sync", client: 0 }, - - { - type: "rename", - client: 1, - oldPath: "A.md", - newPath: "B.md" - }, - - { type: "enable-sync", client: 1 }, - { type: "barrier" }, - - { - type: "assert-consistent", - verify: (s: AssertableState): void => { - s.assertFileCount(1) - .assertFileNotExists("A.md") - .assertContent("B.md", "content-a"); - } - } - ] -}; diff --git a/frontend/sync-client/src/services/server-config.ts b/frontend/sync-client/src/services/server-config.ts index 662304bc..7a341e46 100644 --- a/frontend/sync-client/src/services/server-config.ts +++ b/frontend/sync-client/src/services/server-config.ts @@ -76,7 +76,7 @@ export class ServerConfig { return this.config; } - private startPing(): Promise { + private async startPing(): Promise { const pending = this.syncService.ping().catch((e: unknown) => { if (this.response === pending) { this.response = undefined; diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index c841fc0b..249a6ba3 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -441,7 +441,7 @@ export class SyncEventQueue { newPath: RelativePath ): void { const createEvent = this.findLatestCreateForPath(oldPath); - if (createEvent === undefined) {return;} + if (createEvent === undefined) { return; } const { promise } = createEvent.resolvers; createEvent.path = newPath; From 3d285b0b6ea8b59a0caf14b5b77dd7d956c99644 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 26 Apr 2026 13:13:55 +0100 Subject: [PATCH 32/52] No rate limiting saves --- .../src/views/settings/settings-tab.ts | 61 +++++-------------- .../sync-client/src/persistence/settings.ts | 2 - frontend/sync-client/src/sync-client.ts | 6 +- 3 files changed, 15 insertions(+), 54 deletions(-) diff --git a/frontend/obsidian-plugin/src/views/settings/settings-tab.ts b/frontend/obsidian-plugin/src/views/settings/settings-tab.ts index a0c81522..86b86f3a 100644 --- a/frontend/obsidian-plugin/src/views/settings/settings-tab.ts +++ b/frontend/obsidian-plugin/src/views/settings/settings-tab.ts @@ -155,10 +155,10 @@ export class SyncSettingsTab extends PluginSettingTab { text: "Show history" }, (button) => - (button.onclick = async (): Promise => { - this.plugin.closeSettings(); - await this.plugin.activateView(HistoryView.TYPE); - }) + (button.onclick = async (): Promise => { + this.plugin.closeSettings(); + await this.plugin.activateView(HistoryView.TYPE); + }) ); buttonContainer.createEl( @@ -167,10 +167,10 @@ export class SyncSettingsTab extends PluginSettingTab { text: "Show logs" }, (button) => - (button.onclick = async (): Promise => { - this.plugin.closeSettings(); - await this.plugin.activateView(LogsView.TYPE); - }) + (button.onclick = async (): Promise => { + this.plugin.closeSettings(); + await this.plugin.activateView(LogsView.TYPE); + }) ); } ); @@ -301,7 +301,7 @@ export class SyncSettingsTab extends PluginSettingTab { toggle .setValue( this.syncEnabledOverride ?? - this.syncClient.getSettings().isSyncEnabled + this.syncClient.getSettings().isSyncEnabled ) .setDisabled(this.isApplyingChanges) .setTooltip( @@ -468,39 +468,7 @@ export class SyncSettingsTab extends PluginSettingTab { }) ); - new Setting(containerEl) - .setName("Minimum save interval (ms)") - .setDesc( - "The minimum time between saving settings and database to disk, in milliseconds. Lower values save more frequently but may impact performance." - ) - .addText((input) => - input - .setValue( - this.syncClient - .getSettings() - .minimumSaveIntervalMs.toString() - ) - .onChange(async (value) => { - if (value === "") { - return; - } - let parsedValue = Number.parseInt(value, 10); - if (Number.isNaN(parsedValue) || parsedValue < 0) { - parsedValue = - this.syncClient.getSettings() - .minimumSaveIntervalMs; - } - if (value !== parsedValue.toString()) { - input.setValue(parsedValue.toString()); - } - - return this.syncClient.setSetting( - "minimumSaveIntervalMs", - parsedValue - ); - }) - ); } private setStatusDescriptionSubscription( @@ -524,9 +492,9 @@ export class SyncSettingsTab extends PluginSettingTab { name: string, settingName: keyof SyncSettings ): [ - DocumentFragment, - (newValue: SyncSettings[keyof SyncSettings]) => unknown - ] { + DocumentFragment, + (newValue: SyncSettings[keyof SyncSettings]) => unknown + ] { const titleContainer = document.createDocumentFragment(); const title = titleContainer.createEl("div", { text: name, @@ -536,11 +504,10 @@ export class SyncSettingsTab extends PluginSettingTab { const updateTitle = ( currentValue: SyncSettings[keyof SyncSettings] ): void => { - title.innerText = `${name}${ - currentValue !== this.syncClient.getSettings()[settingName] + title.innerText = `${name}${currentValue !== this.syncClient.getSettings()[settingName] ? " (unsaved)" : "" - }`; + }`; }; return [titleContainer, updateTitle]; diff --git a/frontend/sync-client/src/persistence/settings.ts b/frontend/sync-client/src/persistence/settings.ts index 9771b7f1..b423e09f 100644 --- a/frontend/sync-client/src/persistence/settings.ts +++ b/frontend/sync-client/src/persistence/settings.ts @@ -14,7 +14,6 @@ export interface SyncSettings { diffCacheSizeMB: number; enableTelemetry: boolean; networkRetryIntervalMs: number; - minimumSaveIntervalMs: number; } export const DEFAULT_SETTINGS: SyncSettings = { @@ -29,7 +28,6 @@ export const DEFAULT_SETTINGS: SyncSettings = { diffCacheSizeMB: 4, enableTelemetry: false, networkRetryIntervalMs: 1000, - minimumSaveIntervalMs: 1000 }; export class Settings { diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 99a0221f..a237d4fe 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -150,10 +150,6 @@ export class SyncClient { } ); - const rateLimitedSave = rateLimit( - persistence.save, - () => settings.getSettings().minimumSaveIntervalMs - ); const syncEventQueue = new SyncEventQueue( settings, @@ -161,7 +157,7 @@ export class SyncClient { state.database, async (data): Promise => { state = { ...state, database: data }; - await rateLimitedSave(state); + await persistence.save(state); } ); From 039affff09134c6c8b005a3c15939299926ddb8e Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 26 Apr 2026 13:59:44 +0100 Subject: [PATCH 33/52] More fixes --- .../offline-move-then-remote-delete.test.ts | 3 +- .../src/tests/rename-create-conflict.test.ts | 5 +- ...multaneous-create-delete-same-path.test.ts | 9 +- frontend/sync-client/src/sync-client.ts | 24 ++--- .../src/sync-operations/conflict-path.ts | 4 +- .../src/sync-operations/sync-event-queue.ts | 46 +--------- .../sync-client/src/sync-operations/syncer.ts | 88 +++++++++---------- sync-server/src/app_state/database.rs | 30 ++++--- sync-server/src/app_state/websocket/models.rs | 14 +-- sync-server/src/server/websocket.rs | 3 + 10 files changed, 91 insertions(+), 135 deletions(-) diff --git a/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts index f20211b4..86938bb9 100644 --- a/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-move-then-remote-delete.test.ts @@ -29,8 +29,7 @@ export const offlineMoveThenRemoteDeleteTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertFileNotExists("A.md") - .assertFileNotExists("B.md") + s .assertFileCount(0); } } diff --git a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts index 816c2559..f8558c72 100644 --- a/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-create-conflict.test.ts @@ -9,8 +9,7 @@ export const renameCreateConflictTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "create", client: 0, path: "A.md", content: "hi" }, - { type: "sync", client: 0 }, - { type: "sync", client: 1 }, + { type: "barrier" }, { type: "assert-consistent", verify: (s: AssertableState): void => { @@ -26,7 +25,7 @@ export const renameCreateConflictTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.assertFileNotExists("A.md").assertContent("B.md", "hi"); + s.assertFileCount(2).assertContent("B.md", "hi").assertContent("B (1).md", "hi"); } } ] diff --git a/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts index 8ccb0c8f..7ec116ac 100644 --- a/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts +++ b/frontend/deterministic-tests/src/tests/simultaneous-create-delete-same-path.test.ts @@ -31,14 +31,7 @@ export const simultaneousCreateDeleteSamePathTest: TestDefinition = { { type: "assert-consistent", verify: (s: AssertableState): void => { - s.ifFileExists("A.md", (inner) => - inner - .assertFileCount(1) - .assertContent("A.md", "modified by 1 while offline") - ); - if (!s.files.has("A.md")) { - s.assertFileCount(0); - } + s.assertFileCount(0); } } ] diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index a237d4fe..83dd0e83 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -373,17 +373,6 @@ export class SyncClient { this.syncer.syncLocallyCreatedFile(relativePath); } - public syncLocallyDeletedFile(relativePath: RelativePath): void { - this.checkIfDestroyed("syncLocallyDeletedFile"); - - this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors - if (this.expectedFsEvents.matchDelete(relativePath)) { - return; - } - - this.syncer.syncLocallyDeletedFile(relativePath); - } - public syncLocallyUpdatedFile({ oldPath, relativePath @@ -404,6 +393,19 @@ export class SyncClient { }); } + public syncLocallyDeletedFile(relativePath: RelativePath): void { + this.checkIfDestroyed("syncLocallyDeletedFile"); + + this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors + if (this.expectedFsEvents.matchDelete(relativePath)) { + return; + } + + this.syncer.syncLocallyDeletedFile(relativePath); + } + + + public getDocumentSyncingStatus( relativePath: RelativePath ): DocumentSyncStatus { diff --git a/frontend/sync-client/src/sync-operations/conflict-path.ts b/frontend/sync-client/src/sync-operations/conflict-path.ts index 84efbfe2..264b0a79 100644 --- a/frontend/sync-client/src/sync-operations/conflict-path.ts +++ b/frontend/sync-client/src/sync-operations/conflict-path.ts @@ -17,7 +17,7 @@ function truncateFileNameToByteLimit( maxBytes: number ): string { const encoder = new TextEncoder(); - if (encoder.encode(fileName).byteLength <= maxBytes) {return fileName;} + if (encoder.encode(fileName).byteLength <= maxBytes) { return fileName; } const dotIndex = fileName.lastIndexOf("."); // Dotfile (starts with "." and nothing else) → no extension to preserve. @@ -35,7 +35,7 @@ function truncateFileNameToByteLimit( let usedBytes = 0; for (const { segment } of segmenter.segment(stem)) { const segmentBytes = encoder.encode(segment).byteLength; - if (usedBytes + segmentBytes > stemBudget) {break;} + if (usedBytes + segmentBytes > stemBudget) { break; } truncatedStem += segment; usedBytes += segmentBytes; } diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts index 249a6ba3..0615a4f3 100644 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ b/frontend/sync-client/src/sync-operations/sync-event-queue.ts @@ -38,14 +38,6 @@ export class SyncEventQueue { // It maps pending changes onto the local filesystem. private readonly events: SyncEvent[] = []; - // Tombstones: documents we deleted along with the vaultUpdateId at - // which the delete committed. After we delete, the server may still - // send us older broadcasts for that document (e.g. a backlog update - // committed before the delete from another client). Without these - // entries, the syncer would resurrect the doc by treating an old - // update as a brand-new create. - private readonly deletedDocuments = new Map(); - // file creations for paths matching any of these patterns are ignored // because the user explicitly told us to ignore them. private userIgnorePatterns: RegExp[]; @@ -302,42 +294,6 @@ export class SyncEventQueue { return this.save(); } - /** - * Mark a document as deleted at a given vault-update version. Used by - * the syncer after a successful local or remote delete so future - * obsolete broadcasts for that doc (older parents that arrive late) - * don't resurrect it as a brand-new create. - */ - public recordDeletion( - documentId: DocumentId, - deletedAtVaultUpdateId: VaultUpdateId - ): void { - const existing = this.deletedDocuments.get(documentId); - if (existing !== undefined && existing >= deletedAtVaultUpdateId) { - return; - } - this.deletedDocuments.set(documentId, deletedAtVaultUpdateId); - } - - /** - * Returns the vault-update version at which we last saw this document - * deleted, or `undefined` if we have no record of its deletion. - */ - public getDeletionVersion( - documentId: DocumentId - ): VaultUpdateId | undefined { - return this.deletedDocuments.get(documentId); - } - - /** - * Forget a doc's tombstone — used when a doc with the same id is - * re-introduced (e.g. via a remote create whose server-side state - * surpasses the previous delete). - */ - public clearDeletion(documentId: DocumentId): void { - this.deletedDocuments.delete(documentId); - } - public getDocumentByDocumentId( target: DocumentId ): DocumentWithPath | undefined { @@ -436,7 +392,7 @@ export class SyncEventQueue { return undefined; } - private updatePendingCreatePath( + public updatePendingCreatePath( oldPath: RelativePath, newPath: RelativePath ): void { diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index fffa0300..e9f0050e 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -217,8 +217,8 @@ export class Syncer { } private ensureDraining(): void { - if (this.drainPromise !== undefined) {return;} - if (this.isScanning) {return;} + if (this.drainPromise !== undefined) { return; } + if (this.isScanning) { return; } this.drainPromise = this.drain().finally(() => { this.drainPromise = undefined; }); @@ -318,7 +318,7 @@ export class Syncer { private async skipIfOversized(event: SyncEvent): Promise { let sizeInBytes = 0; - let relativePath: RelativePath = ""; + let relativePath: RelativePath; switch (event.type) { case SyncEventType.LocalDelete: @@ -329,7 +329,7 @@ export class Syncer { relativePath = event.path; break; case SyncEventType.RemoteChange: - if (event.remoteVersion.isDeleted) {return false;} + if (event.remoteVersion.isDeleted) { return false; } sizeInBytes = event.remoteVersion.contentSize; ({ relativePath } = event.remoteVersion); break; @@ -339,7 +339,7 @@ export class Syncer { sizeInBytes, relativePath ); - if (oversizedEntry === undefined) {return false;} + if (oversizedEntry === undefined) { return false; } this.history.addHistoryEntry(oversizedEntry); @@ -417,22 +417,24 @@ export class Syncer { ); return; } - const relativePath = doc.path; const response = await this.syncService.delete({ documentId, - relativePath + relativePath: doc.path }); - await this.queue.removeDocument(doc.path); - this.queue.recordDeletion(documentId, response.vaultUpdateId); - this.queue.lastSeenUpdateId = response.vaultUpdateId; - + // Don't remove the doc from the queue or advance lastSeenUpdateId + // here. The server broadcasts the delete back to us over the + // WebSocket; that receipt drives `processRemoteDelete`'s cleanup + // and history entry. Keeping the entry in the map until then lets + // late remote updates be recognised as "file is missing" and + // skipped, instead of resurrecting the doc. + // this.history.addHistoryEntry({ status: SyncStatus.SUCCESS, details: { type: SyncType.DELETE, - relativePath + relativePath: doc.path }, message: "Successfully deleted file on the server", author: response.userId @@ -520,7 +522,7 @@ export class Syncer { parentVersionId: response.vaultUpdateId, remoteRelativePath: response.relativePath }; - let remoteHash = ""; + let remoteHash: string; if ("type" in response && response.type === "MergingUpdate") { const responseBytes = base64ToBytes(response.contentBase64); @@ -565,16 +567,16 @@ export class Syncer { `Document ${response.documentId} is no longer tracked after update; cannot reconcile potential rename` ); } else { - const currentPath = tracked.path ?? path; + const currentPath = tracked.path; if (currentPath === path) { // a http response will always be more up-to-date than any queued remote update // move will always move to the relative path when MoveOnConflict.EXISTING is given await this.operations.move( - path, + currentPath, response.relativePath, MoveOnConflict.EXISTING ); - + this.queue.updatePendingCreatePath(currentPath, response.relativePath); await this.queue.setDocument(response.relativePath, { ...record, remoteHash @@ -597,13 +599,13 @@ export class Syncer { // consistent. Without this, a later remote create at the // originally-requested path would see a phantom local conflict // and stash the new file under a `conflict--` path. - if (response.relativePath !== createEvent.path) { + if (response.relativePath !== createEvent.originalPath) { await this.operations.move( createEvent.path, response.relativePath, MoveOnConflict.EXISTING ); - createEvent.path = response.relativePath; + this.queue.updatePendingCreatePath(createEvent.path, response.relativePath); } await this.queue.resolveCreate(createEvent, { ...record, @@ -624,7 +626,12 @@ export class Syncer { if (remoteVersion.isDeleted) { if (documentWithPath === undefined) { - // trying to delete a document we've already scheduled for deletion locally + // The doc isn't tracked locally — either we never had + // it (joined the vault after the delete) or a previous + // delete already cleaned it up. Just advance + // `lastSeenUpdateId` so we don't replay this on the + // next reconnect. + this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; return; } return this.processRemoteDelete( @@ -633,24 +640,6 @@ export class Syncer { ); } - // The doc was deleted at-or-after the version this broadcast - // describes (e.g. another client's update committed before our - // local delete; the server's backlog is replaying it now). Apply - // would resurrect a doc we deliberately removed. - const deletedAt = this.queue.getDeletionVersion( - remoteVersion.documentId - ); - if ( - deletedAt !== undefined && - deletedAt >= remoteVersion.vaultUpdateId - ) { - this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; - this.logger.debug( - `Skipping obsolete remote update for already-deleted document ${remoteVersion.documentId} (V=${remoteVersion.vaultUpdateId} <= deleted V=${deletedAt})` - ); - return; - } - if ( (documentWithPath?.record.parentVersionId ?? 0) >= remoteVersion.vaultUpdateId @@ -663,7 +652,22 @@ export class Syncer { } if (documentWithPath !== undefined) { - // must be the update to an existing doc + // The doc is tracked. If the local file backing it has + // gone missing — e.g. the user deleted it and the + // LocalDelete hasn't drained yet, or our HTTP DELETE just + // landed and we're still waiting on the WebSocket receipt + // — ignore the update. Otherwise we'd try to operate on a + // vanished file (or recreate one we're tearing down). + const fileExists = await this.operations.exists( + documentWithPath.path + ); + if (!fileExists) { + this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; + this.logger.debug( + `Ignoring remote update for ${remoteVersion.documentId}: local file at ${documentWithPath.path} is missing` + ); + return; + } return this.processRemoteUpdate( documentWithPath.path, documentWithPath.record, @@ -671,10 +675,6 @@ export class Syncer { ); } - const pendingCreate = this.queue.findLatestCreateForPath( - remoteVersion.relativePath - ); - return this.processRemoteCreateForNewDocument(remoteVersion); } @@ -684,10 +684,6 @@ export class Syncer { ): Promise { await this.operations.delete(path); await this.queue.removeDocument(path); - this.queue.recordDeletion( - remoteVersion.documentId, - remoteVersion.vaultUpdateId - ); this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; diff --git a/sync-server/src/app_state/database.rs b/sync-server/src/app_state/database.rs index 8e505083..9fa76234 100644 --- a/sync-server/src/app_state/database.rs +++ b/sync-server/src/app_state/database.rs @@ -717,19 +717,23 @@ impl Database { .await .context("Failed to commit transaction")?; - // The broadcast is delivered to every connected client except the - // author — the send task filters on `origin_device_id` (see - // `websocket.rs`). The origin already has authoritative state - // from the HTTP response that triggered this write. - self.broadcasts.send_document_update( - vault_id.clone(), - WebSocketServerMessageWithOrigin::with_origin( - version.device_id.clone(), - WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { - document: version.clone().into(), - }), - ), - ); + // For non-delete writes the originating device already has + // authoritative state from its HTTP response, so we tag the + // broadcast with `origin_device_id` and the send task in + // `websocket.rs` filters it out for that device. Deletes are + // delivered to *every* connected client including the author — + // the originator only removes the document from its sync queue + // once it receives this receipt. + let envelope = WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { + document: version.clone().into(), + }); + let with_origin = if version.is_deleted { + WebSocketServerMessageWithOrigin::new(envelope) + } else { + WebSocketServerMessageWithOrigin::with_origin(version.device_id.clone(), envelope) + }; + self.broadcasts + .send_document_update(vault_id.clone(), with_origin); Ok(()) } diff --git a/sync-server/src/app_state/websocket/models.rs b/sync-server/src/app_state/websocket/models.rs index 983c0dad..eb6c956a 100644 --- a/sync-server/src/app_state/websocket/models.rs +++ b/sync-server/src/app_state/websocket/models.rs @@ -58,11 +58,15 @@ pub struct CursorPositionFromServer { pub clients: Vec, } -// One committed version, broadcast to every connected client *except* -// the device that authored it — that device already has the new state -// via its HTTP response. The server also emits these one-at-a-time to -// catch up a freshly-connected client on versions committed while it -// was offline, in ascending `vault_update_id` order. +// One committed version. Non-delete updates are broadcast to every +// connected client *except* the device that authored them — that +// device already has the new state via its HTTP response. Deletes are +// broadcast to every client including the author: the author keeps +// the document in its sync queue until this receipt arrives so a late +// remote update can't sneak in between the HTTP response and the +// queue cleanup. The server also emits these one-at-a-time to catch +// up a freshly-connected client on versions committed while it was +// offline, in ascending `vault_update_id` order. #[derive(TS, Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct WebSocketVaultUpdate { diff --git a/sync-server/src/server/websocket.rs b/sync-server/src/server/websocket.rs index 46d67533..226a2c92 100644 --- a/sync-server/src/server/websocket.rs +++ b/sync-server/src/server/websocket.rs @@ -165,6 +165,9 @@ async fn websocket( Ok(update) => { // Drop messages this device authored because the HTTP // response already carried authoritative state back. + // Delete broadcasts are sent without an origin so the + // author also receives them — that's the receipt the + // client needs to drop the doc from its sync queue. if Some(&device_id) == update.origin_device_id.as_ref() { continue; } From 81c7e0c984d32b5855deacc8b8837c60b69550eb Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 26 Apr 2026 15:35:58 +0100 Subject: [PATCH 34/52] Fix test --- frontend/sync-client/src/sync-operations/syncer.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index e9f0050e..257a8d67 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -477,6 +477,11 @@ export class Syncer { contentBytes }); + if (response.isDeleted) { + await this.processRemoteDelete(diskPath, { ...response, contentSize: 0 }); + return; + } + this.queue.lastSeenUpdateId = response.vaultUpdateId; await this.handleMaybeMergingResponse({ From 439de6a2643f75e12852638fd305db941bfe4893 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 26 Apr 2026 18:19:01 +0100 Subject: [PATCH 35/52] Remove clutter --- frontend/deterministic-tests/src/managed-websocket.ts | 2 +- frontend/deterministic-tests/src/utils/find-free-port.ts | 2 +- frontend/history-ui/src/lib/stores.svelte.ts | 2 +- frontend/local-client-cli/src/args.ts | 4 ++-- frontend/sync-client/src/persistence/database.ts | 2 -- .../src/services/types/UpdateDocumentVersion.ts | 7 ------- frontend/sync-client/src/sync-operations/types.ts | 2 +- frontend/sync-client/src/tracing/sync-history.ts | 2 +- scripts/update-api-types.sh | 5 +++++ 9 files changed, 12 insertions(+), 16 deletions(-) delete mode 100644 frontend/sync-client/src/persistence/database.ts delete mode 100644 frontend/sync-client/src/services/types/UpdateDocumentVersion.ts diff --git a/frontend/deterministic-tests/src/managed-websocket.ts b/frontend/deterministic-tests/src/managed-websocket.ts index c97a43a0..d8801d1b 100644 --- a/frontend/deterministic-tests/src/managed-websocket.ts +++ b/frontend/deterministic-tests/src/managed-websocket.ts @@ -8,7 +8,7 @@ * constructor using Object.defineProperty so we don't need conflicting * get/set accessor pairs. */ -export class ManagedWebSocket implements WebSocket { +class ManagedWebSocket implements WebSocket { public static readonly CONNECTING = WebSocket.CONNECTING; public static readonly OPEN = WebSocket.OPEN; public static readonly CLOSING = WebSocket.CLOSING; diff --git a/frontend/deterministic-tests/src/utils/find-free-port.ts b/frontend/deterministic-tests/src/utils/find-free-port.ts index 3c965049..0734c1a9 100644 --- a/frontend/deterministic-tests/src/utils/find-free-port.ts +++ b/frontend/deterministic-tests/src/utils/find-free-port.ts @@ -1,6 +1,6 @@ import * as net from "node:net"; -export interface PortReservation { +interface PortReservation { port: number; release: () => void; } diff --git a/frontend/history-ui/src/lib/stores.svelte.ts b/frontend/history-ui/src/lib/stores.svelte.ts index 458ec5e7..8b268eb6 100644 --- a/frontend/history-ui/src/lib/stores.svelte.ts +++ b/frontend/history-ui/src/lib/stores.svelte.ts @@ -79,7 +79,7 @@ class NavStore { export const nav = new NavStore(); // Toasts -export interface Toast { +interface Toast { id: number; message: string; type: "success" | "error" | "info"; diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts index 3652a4c7..442c4817 100644 --- a/frontend/local-client-cli/src/args.ts +++ b/frontend/local-client-cli/src/args.ts @@ -2,9 +2,9 @@ import { Command, Option } from "commander"; import packageJson from "../package.json"; import { LogLevel } from "sync-client"; -export type LineEndingMode = "auto" | "lf" | "crlf"; +type LineEndingMode = "auto" | "lf" | "crlf"; -export interface CliArgs { +interface CliArgs { remoteUri: string; token: string; vaultName: string; diff --git a/frontend/sync-client/src/persistence/database.ts b/frontend/sync-client/src/persistence/database.ts deleted file mode 100644 index 72f15fbd..00000000 --- a/frontend/sync-client/src/persistence/database.ts +++ /dev/null @@ -1,2 +0,0 @@ -// This file is intentionally empty -// All document tracking has been moved to sync-event-queue.ts diff --git a/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts b/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts deleted file mode 100644 index 4e57a297..00000000 --- a/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export interface UpdateDocumentVersion { - parent_version_id: bigint; - relative_path: string; - content: number[]; -} diff --git a/frontend/sync-client/src/sync-operations/types.ts b/frontend/sync-client/src/sync-operations/types.ts index 4cdac588..7a15aedd 100644 --- a/frontend/sync-client/src/sync-operations/types.ts +++ b/frontend/sync-client/src/sync-operations/types.ts @@ -16,7 +16,7 @@ export interface DocumentWithPath { record: DocumentRecord; } -export interface StoredDocument extends DocumentRecord { +interface StoredDocument extends DocumentRecord { relativePath: RelativePath; } diff --git a/frontend/sync-client/src/tracing/sync-history.ts b/frontend/sync-client/src/tracing/sync-history.ts index 88b699fe..362d98e9 100644 --- a/frontend/sync-client/src/tracing/sync-history.ts +++ b/frontend/sync-client/src/tracing/sync-history.ts @@ -28,7 +28,7 @@ export interface SyncDeleteDetails { relativePath: RelativePath; } -export interface SyncSkippedDetails { +interface SyncSkippedDetails { type: SyncType.SKIPPED; relativePath: RelativePath; } diff --git a/scripts/update-api-types.sh b/scripts/update-api-types.sh index 1f10944c..e460214b 100755 --- a/scripts/update-api-types.sh +++ b/scripts/update-api-types.sh @@ -8,7 +8,12 @@ cd sync-server cargo test export_bindings cd - +# sync-client/src/services/types contains only generated bindings — wipe and copy +rm -f frontend/sync-client/src/services/types/*.ts cp -r sync-server/bindings/* frontend/sync-client/src/services/types/ + +# history-ui/src/lib/types contains generated bindings plus a hand-written index.ts +find frontend/history-ui/src/lib/types -maxdepth 1 -name "*.ts" ! -name "index.ts" -delete cp -r sync-server/bindings/* frontend/history-ui/src/lib/types/ cd frontend From 0ab6984cdf32ae97a7aadc32da4cae6962e7baa6 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Sun, 26 Apr 2026 18:25:05 +0100 Subject: [PATCH 36/52] Clear up index --- .../src/components/ActivityFeed.svelte | 2 +- .../src/components/Dashboard.svelte | 9 ++---- .../src/components/DocumentDetail.svelte | 8 ++--- .../history-ui/src/components/FileTree.svelte | 2 +- .../src/components/TimeSlider.svelte | 2 +- .../src/components/VaultPicker.svelte | 2 +- frontend/history-ui/src/lib/api.ts | 16 +++++----- frontend/history-ui/src/lib/stores.svelte.ts | 10 ++---- frontend/history-ui/src/lib/types/index.ts | 31 ------------------- frontend/history-ui/src/lib/view-types.ts | 22 +++++++++++++ scripts/update-api-types.sh | 6 ++-- 11 files changed, 44 insertions(+), 66 deletions(-) delete mode 100644 frontend/history-ui/src/lib/types/index.ts create mode 100644 frontend/history-ui/src/lib/view-types.ts diff --git a/frontend/history-ui/src/components/ActivityFeed.svelte b/frontend/history-ui/src/components/ActivityFeed.svelte index c1c82c29..b20991e2 100644 --- a/frontend/history-ui/src/components/ActivityFeed.svelte +++ b/frontend/history-ui/src/components/ActivityFeed.svelte @@ -1,5 +1,5 @@