Fix cursor tracker

This commit is contained in:
Andras Schmelczer 2026-09-19 22:22:18 +01:00
commit 9bb219734f
2 changed files with 90 additions and 10 deletions

View file

@ -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<void>
>(),
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"
);
});

View file

@ -72,21 +72,27 @@ export class CursorTracker {
} }
); );
this.fileChangeNotifier.onFileChanged.add(async (relativePath) => this.fileChangeNotifier.onFileChanged.add(async (relativePath) => {
this.updateLock.withLock(async () => { const cursors = await this.updateLock.withLock(async () => {
for (const clientCursor of this.knownRemoteCursors) { for (const clientCursor of this.knownRemoteCursors) {
if ( if (
clientCursor.documentsWithCursors.some( clientCursor.documentsWithCursors.some(
(document) => (document) =>
document.relative_path === relativePath document.relative_path === relativePath ||
this.database.getDocumentByDocumentId(
document.document_id
)?.relativePath === relativePath
) )
) { ) {
clientCursor.upToDateness = clientCursor.upToDateness =
await this.getDocumentsUpToDateness(clientCursor); await this.getDocumentsUpToDateness(clientCursor);
} }
} }
}) return this.getRelevantAndPruneKnownClientCursors();
); });
this.onRemoteCursorsUpdated.trigger(cursors);
});
} }
/// Update the local cursors for the given documents. /// Update the local cursors for the given documents.
@ -166,13 +172,20 @@ export class CursorTracker {
const result: MaybeOutdatedClientCursors[] = []; const result: MaybeOutdatedClientCursors[] = [];
const included = new Set<string>(); const included = new Set<string>();
const relevantCursors = []; const retainedCursors = [];
const retainedFuture = new Set<string>();
for (const clientCursors of [...this.knownRemoteCursors].reverse()) { 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; continue;
} }
if (clientCursors.upToDateness === DocumentUpToDateness.Later) { if (included.has(clientCursors.deviceId)) {
continue; continue;
} }
@ -192,10 +205,10 @@ export class CursorTracker {
}); });
included.add(clientCursors.deviceId); 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; return result;
} }