diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts new file mode 100644 index 00000000..01eac85a --- /dev/null +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ +import assert from "node:assert/strict"; +import { it } from "node:test"; +import type { Database } from "../persistence/database"; +import type { FileOperations } from "../file-operations/file-operations"; +import type { WebSocketManager } from "../services/websocket-manager"; +import type { ClientCursors } from "../services/types/ClientCursors"; +import { DocumentUpToDateness } from "../types/document-up-to-dateness"; +import { EventListeners } from "../utils/data-structures/event-listeners"; +import { hash } from "../utils/hash"; +import { CursorTracker } from "./cursor-tracker"; +import { FileChangeNotifier } from "./file-change-notifier"; + +it("retains future cursors and publishes them when content catches up", async () => { + const content = new TextEncoder().encode("current"); + const contentHash = await hash(content); + const database = { + getDocumentByDocumentId: () => ({ + documentId: "document", + relativePath: "note.md", + isDeleted: false, + metadata: { parentVersionId: 2, hash: contentHash } + }) + } as unknown as Database; + const websocket = { + onRemoteCursorsUpdateReceived: new EventListeners< + (cursors: ClientCursors[]) => Promise + >(), + updateLocalCursors: () => {} + } as unknown as WebSocketManager; + const files = { read: async () => content } as unknown as FileOperations; + const notifier = new FileChangeNotifier(); + const tracker = new CursorTracker(database, websocket, files, notifier); + const future = { + userName: "Other", + deviceId: "device", + documentsWithCursors: [ + { + document_id: "document", + relative_path: "old-name.md", + vault_update_id: 2, + cursors: [{ start: 0, end: 1 }] + } + ], + upToDateness: DocumentUpToDateness.Later + }; + const internals = tracker as unknown as { + knownRemoteCursors: (typeof future)[]; + getRelevantAndPruneKnownClientCursors: () => ClientCursors[]; + }; + internals.knownRemoteCursors = [future]; + + assert.deepEqual(internals.getRelevantAndPruneKnownClientCursors(), []); + assert.strictEqual(internals.knownRemoteCursors.length, 1); + + let received: ClientCursors[] = []; + tracker.onRemoteCursorsUpdated.add((cursors) => { + received = cursors; + }); + await notifier.onFileChanged.triggerAsync("note.md"); + + assert.strictEqual(received.length, 1); + assert.strictEqual( + received[0].documentsWithCursors[0].relative_path, + "note.md" + ); +}); diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.ts index a3d37885..e3c63cd4 100644 --- a/frontend/sync-client/src/sync-operations/cursor-tracker.ts +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.ts @@ -72,21 +72,27 @@ export class CursorTracker { } ); - this.fileChangeNotifier.onFileChanged.add(async (relativePath) => - this.updateLock.withLock(async () => { + this.fileChangeNotifier.onFileChanged.add(async (relativePath) => { + const cursors = await this.updateLock.withLock(async () => { for (const clientCursor of this.knownRemoteCursors) { if ( clientCursor.documentsWithCursors.some( (document) => - document.relative_path === relativePath + document.relative_path === relativePath || + this.database.getDocumentByDocumentId( + document.document_id + )?.relativePath === relativePath ) ) { clientCursor.upToDateness = await this.getDocumentsUpToDateness(clientCursor); } } - }) - ); + return this.getRelevantAndPruneKnownClientCursors(); + }); + + this.onRemoteCursorsUpdated.trigger(cursors); + }); } /// Update the local cursors for the given documents. @@ -166,13 +172,20 @@ export class CursorTracker { const result: MaybeOutdatedClientCursors[] = []; const included = new Set(); - const relevantCursors = []; + const retainedCursors = []; + const retainedFuture = new Set(); for (const clientCursors of [...this.knownRemoteCursors].reverse()) { - if (included.has(clientCursors.deviceId)) { + if (clientCursors.upToDateness === DocumentUpToDateness.Later) { + // Retain the latest future position so it can become relevant + // after the corresponding content or manifest is applied. + if (!retainedFuture.has(clientCursors.deviceId)) { + retainedCursors.unshift(clientCursors); + retainedFuture.add(clientCursors.deviceId); + } continue; } - if (clientCursors.upToDateness === DocumentUpToDateness.Later) { + if (included.has(clientCursors.deviceId)) { continue; } @@ -192,10 +205,10 @@ export class CursorTracker { }); included.add(clientCursors.deviceId); - relevantCursors.unshift(clientCursors); // to reverse order back to normal + retainedCursors.unshift(clientCursors); // to reverse order back to normal } - this.knownRemoteCursors = relevantCursors; + this.knownRemoteCursors = retainedCursors; return result; }