304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unsafe-type-assertion */
|
|
import assert from "node:assert/strict";
|
|
import { it } from "node:test";
|
|
import {
|
|
Database,
|
|
createEmptyDatabase,
|
|
type RelativePath,
|
|
type VaultUpdateId
|
|
} from "../persistence/database";
|
|
import type { FileSystemOperations } from "../file-operations/filesystem-operations";
|
|
import type { WebSocketManager } from "../services/websocket-manager";
|
|
import type { ClientCursors } from "../services/types/ClientCursors";
|
|
import { EventListeners } from "../utils/data-structures/event-listeners";
|
|
import { hash } from "../utils/hash";
|
|
import { CursorTracker } from "./cursor-tracker";
|
|
|
|
function createDatabase(digest: string): Database {
|
|
const state = createEmptyDatabase("test");
|
|
state.local.document = "note.md";
|
|
state.documents.document = { base: { vaultUpdateId: 1, hash: digest } };
|
|
return new Database(
|
|
state,
|
|
async () => undefined,
|
|
"test",
|
|
async () => undefined
|
|
);
|
|
}
|
|
|
|
it("keeps the last usable cursor while retaining only the latest future update", async (context) => {
|
|
const content = new TextEncoder().encode("current");
|
|
const contentHash = await hash(content);
|
|
const database = createDatabase(contentHash);
|
|
const websocket = {
|
|
onWebSocketStatusChanged: new EventListeners(),
|
|
onRemoteCursorsUpdateReceived: new EventListeners<
|
|
(cursors: ClientCursors[]) => Promise<void>
|
|
>(),
|
|
updateLocalCursors: () => undefined
|
|
} as unknown as WebSocketManager;
|
|
const notifier = new EventListeners<(path: RelativePath) => unknown>();
|
|
const tracker = new CursorTracker(
|
|
database,
|
|
websocket,
|
|
{ read: async () => ({ content }) } as unknown as FileSystemOperations,
|
|
notifier
|
|
);
|
|
context.after(() => {
|
|
tracker.reset();
|
|
});
|
|
let received: ClientCursors[] = [];
|
|
tracker.onRemoteCursorsUpdated.add((cursors) => {
|
|
received = cursors;
|
|
});
|
|
const cursor = (version: number): ClientCursors => ({
|
|
userName: "Other",
|
|
deviceId: "device",
|
|
documentsWithCursors: [
|
|
{
|
|
document_id: "document",
|
|
relative_path: "old-name.md",
|
|
vault_update_id: version,
|
|
cursors: [{ start: version, end: version }]
|
|
}
|
|
]
|
|
});
|
|
for (const remoteVersion of [1, 2, 3]) {
|
|
await websocket.onRemoteCursorsUpdateReceived.triggerAsync([
|
|
cursor(remoteVersion)
|
|
]);
|
|
assert.equal(received[0]?.documentsWithCursors[0]?.vault_update_id, 1);
|
|
}
|
|
|
|
assert.ok(database.state.documents.document);
|
|
database.state.documents.document.base = {
|
|
vaultUpdateId: 2,
|
|
hash: contentHash
|
|
};
|
|
await notifier.triggerAsync("note.md");
|
|
assert.equal(
|
|
received[0]?.documentsWithCursors[0]?.vault_update_id,
|
|
1,
|
|
"superseded future cursors must not reappear"
|
|
);
|
|
database.state.documents.document.base = {
|
|
vaultUpdateId: 3,
|
|
hash: contentHash
|
|
};
|
|
await notifier.triggerAsync("note.md");
|
|
assert.equal(received[0].documentsWithCursors[0].vault_update_id, 3);
|
|
assert.equal(received[0].documentsWithCursors[0].relative_path, "note.md");
|
|
await websocket.onRemoteCursorsUpdateReceived.triggerAsync([]);
|
|
assert.deepEqual(received, []);
|
|
});
|
|
|
|
it(
|
|
"refreshes unchanged cursors on reconnect and heartbeat, and follows the document through renames",
|
|
{ timeout: 5_000 },
|
|
async (context) => {
|
|
context.mock.timers.enable({ apis: ["setInterval"] });
|
|
const content = new TextEncoder().encode("hello");
|
|
let path = "note.md";
|
|
const database = createDatabase(await hash(content));
|
|
const sent: {
|
|
documentsWithCursors: {
|
|
relative_path: RelativePath;
|
|
vault_update_id: VaultUpdateId | null;
|
|
}[];
|
|
}[] = [];
|
|
let published = Promise.withResolvers<undefined>();
|
|
const websocket = {
|
|
onWebSocketStatusChanged: new EventListeners<
|
|
(connected: boolean) => unknown
|
|
>(),
|
|
onRemoteCursorsUpdateReceived: new EventListeners(),
|
|
updateLocalCursors: (value: (typeof sent)[number]) => {
|
|
sent.push(value);
|
|
published.resolve(undefined);
|
|
}
|
|
} as unknown as WebSocketManager;
|
|
const notifier = new EventListeners<(path: RelativePath) => unknown>();
|
|
const read = context.mock.fn(async () => ({ content }));
|
|
const tracker = new CursorTracker(
|
|
database,
|
|
websocket,
|
|
{ read } as unknown as FileSystemOperations,
|
|
notifier
|
|
);
|
|
const waitForSend = async (trigger: () => void): Promise<void> => {
|
|
published = Promise.withResolvers<undefined>();
|
|
trigger();
|
|
// Hashing finishes outside the event loop; a fixed number of turns
|
|
// cannot guarantee that the cursor snapshot has been published.
|
|
await published.promise;
|
|
};
|
|
|
|
try {
|
|
await tracker.sendLocalCursorsToServer({
|
|
"note.md": [{ start: 1, end: 2 }]
|
|
});
|
|
assert.equal(sent.length, 1);
|
|
await tracker.sendLocalCursorsToServer({
|
|
"note.md": [{ start: 1, end: 2 }]
|
|
});
|
|
assert.equal(sent.length, 1);
|
|
await waitForSend(() => {
|
|
websocket.onWebSocketStatusChanged.trigger(true);
|
|
});
|
|
assert.equal(sent.length, 2);
|
|
await waitForSend(() => {
|
|
context.mock.timers.tick(15_000);
|
|
});
|
|
assert.equal(sent.length, 3);
|
|
path = "renamed.md";
|
|
database.state.local.document = path;
|
|
await notifier.triggerAsync(path);
|
|
assert.equal(
|
|
sent.at(-1)?.documentsWithCursors[0]?.relative_path,
|
|
path
|
|
);
|
|
websocket.onWebSocketStatusChanged.trigger(false);
|
|
const count = Number(sent.length);
|
|
const readCount = read.mock.callCount();
|
|
context.mock.timers.tick(30_000);
|
|
// A surviving heartbeat would start a snapshot read in the send lock's
|
|
// next microtask, even if hashing that snapshot has not finished yet.
|
|
await Promise.resolve();
|
|
assert.equal(read.mock.callCount(), readCount);
|
|
assert.equal(sent.length, count);
|
|
await waitForSend(() => {
|
|
websocket.onWebSocketStatusChanged.trigger(true);
|
|
});
|
|
assert.equal(sent.length, count + 1);
|
|
} finally {
|
|
tracker.reset();
|
|
context.mock.timers.reset();
|
|
}
|
|
}
|
|
);
|
|
|
|
for (const interruption of ["disconnect", "reset"] as const) {
|
|
it(`an in-flight cursor read cannot resurrect presence after ${interruption}`, async () => {
|
|
const content = new TextEncoder().encode("current");
|
|
const contentHash = await hash(content);
|
|
const entered = Promise.withResolvers<undefined>();
|
|
const release = Promise.withResolvers<undefined>();
|
|
const database = createDatabase(contentHash);
|
|
const websocket = {
|
|
onWebSocketStatusChanged: new EventListeners<
|
|
(connected: boolean) => unknown
|
|
>(),
|
|
onRemoteCursorsUpdateReceived: new EventListeners<
|
|
(cursors: ClientCursors[]) => Promise<void>
|
|
>(),
|
|
updateLocalCursors: () => undefined
|
|
} as unknown as WebSocketManager;
|
|
const tracker = new CursorTracker(
|
|
database,
|
|
websocket,
|
|
{
|
|
read: async () => {
|
|
entered.resolve(undefined);
|
|
await release.promise;
|
|
return { content };
|
|
}
|
|
} as unknown as FileSystemOperations,
|
|
new EventListeners<(path: RelativePath) => unknown>()
|
|
);
|
|
let received: ClientCursors[] = [];
|
|
tracker.onRemoteCursorsUpdated.add((cursors) => {
|
|
received = cursors;
|
|
});
|
|
const cursor: ClientCursors = {
|
|
userName: "Other",
|
|
deviceId: "device",
|
|
documentsWithCursors: [
|
|
{
|
|
document_id: "document",
|
|
relative_path: "note.md",
|
|
vault_update_id: 1,
|
|
cursors: [{ start: 0, end: 1 }]
|
|
}
|
|
]
|
|
};
|
|
const first = websocket.onRemoteCursorsUpdateReceived.triggerAsync([
|
|
cursor
|
|
]);
|
|
await entered.promise;
|
|
const queued = websocket.onRemoteCursorsUpdateReceived.triggerAsync([
|
|
cursor
|
|
]);
|
|
if (interruption === "reset") {
|
|
tracker.reset();
|
|
} else {
|
|
websocket.onWebSocketStatusChanged.trigger(false);
|
|
}
|
|
|
|
assert.deepEqual(received, []);
|
|
release.resolve(undefined);
|
|
await first;
|
|
await queued;
|
|
assert.deepEqual(
|
|
received,
|
|
[],
|
|
"a retired connection must not restore its cursors"
|
|
);
|
|
tracker.reset();
|
|
});
|
|
}
|
|
|
|
it("a remote cursor becomes outdated if the document advances during its snapshot read", async () => {
|
|
const content = new TextEncoder().encode("old");
|
|
const database = createDatabase(await hash(content));
|
|
const entered = Promise.withResolvers<undefined>(),
|
|
release = Promise.withResolvers<undefined>();
|
|
const websocket = {
|
|
onWebSocketStatusChanged: new EventListeners<
|
|
(connected: boolean) => unknown
|
|
>(),
|
|
onRemoteCursorsUpdateReceived: new EventListeners<
|
|
(cursors: ClientCursors[]) => Promise<void>
|
|
>(),
|
|
updateLocalCursors: () => undefined
|
|
} as unknown as WebSocketManager;
|
|
const tracker = new CursorTracker(
|
|
database,
|
|
websocket,
|
|
{
|
|
read: async () => {
|
|
entered.resolve(undefined);
|
|
await release.promise;
|
|
return { content };
|
|
}
|
|
} as unknown as FileSystemOperations,
|
|
new EventListeners<(path: RelativePath) => unknown>()
|
|
);
|
|
let outdated: boolean | undefined = undefined;
|
|
tracker.onRemoteCursorsUpdated.add((cursors) => {
|
|
outdated = cursors[0]?.isOutdated;
|
|
});
|
|
const update = websocket.onRemoteCursorsUpdateReceived.triggerAsync([
|
|
{
|
|
userName: "Other",
|
|
deviceId: "device",
|
|
documentsWithCursors: [
|
|
{
|
|
document_id: "document",
|
|
relative_path: "note.md",
|
|
vault_update_id: 1,
|
|
cursors: [{ start: 0, end: 1 }]
|
|
}
|
|
]
|
|
}
|
|
]);
|
|
await entered.promise;
|
|
assert.ok(database.state.documents.document);
|
|
database.state.documents.document.base = {
|
|
vaultUpdateId: 2,
|
|
hash: await hash(new TextEncoder().encode("new content"))
|
|
};
|
|
release.resolve(undefined);
|
|
await update;
|
|
assert.equal(outdated, true);
|
|
tracker.reset();
|
|
});
|