WIP
This commit is contained in:
parent
0e3e5a99cd
commit
d034ad5cb3
50 changed files with 6515 additions and 1492 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import type { FileOperations } from "../file-operations/file-operations";
|
||||
import type { Database, RelativePath } from "../persistence/database";
|
||||
import type { RelativePath } from "./types";
|
||||
import type { SyncEventQueue } from "./sync-event-queue";
|
||||
import type { ClientCursors } from "../services/types/ClientCursors";
|
||||
import type { CursorSpan } from "../services/types/CursorSpan";
|
||||
import type { DocumentWithCursors } from "../services/types/DocumentWithCursors";
|
||||
|
|
@ -35,7 +36,7 @@ export class CursorTracker {
|
|||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly database: Database,
|
||||
private readonly queue: SyncEventQueue,
|
||||
private readonly webSocketManager: WebSocketManager,
|
||||
private readonly fileOperations: FileOperations,
|
||||
private readonly fileChangeNotifier: FileChangeNotifier
|
||||
|
|
@ -104,21 +105,16 @@ export class CursorTracker {
|
|||
for (const [relativePath, cursors] of Object.entries(
|
||||
documentToCursors
|
||||
)) {
|
||||
const record =
|
||||
this.database.getLatestDocumentByRelativePath(relativePath);
|
||||
const record = this.queue.getDocument(relativePath);
|
||||
|
||||
if (!record) {
|
||||
continue; // Let's wait for the file to be created before sending cursors
|
||||
}
|
||||
|
||||
if (!record.metadata) {
|
||||
continue; // this is a new document, no need to sync the cursors
|
||||
}
|
||||
|
||||
documentsWithCursors.push({
|
||||
relative_path: relativePath,
|
||||
document_id: record.metadata.documentId,
|
||||
vault_update_id: record.metadata.parentVersionId,
|
||||
document_id: record.documentId,
|
||||
vault_update_id: record.parentVersionId,
|
||||
cursors: cursors.map(({ start, end }) => ({
|
||||
start: Math.min(start, end),
|
||||
end: Math.max(start, end)
|
||||
|
|
@ -139,10 +135,8 @@ export class CursorTracker {
|
|||
const readContent = await this.fileOperations.read(
|
||||
doc.relative_path
|
||||
);
|
||||
const record = this.database.getLatestDocumentByRelativePath(
|
||||
doc.relative_path
|
||||
);
|
||||
if (record?.metadata?.hash !== (await hash(readContent))) {
|
||||
const record = this.queue.getDocument(doc.relative_path);
|
||||
if (record?.hash !== (await hash(readContent))) {
|
||||
doc.vault_update_id = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -227,9 +221,7 @@ export class CursorTracker {
|
|||
private async getDocumentUpToDateness(
|
||||
document: DocumentWithCursors
|
||||
): Promise<DocumentUpToDateness> {
|
||||
const record = this.database.getLatestDocumentByRelativePath(
|
||||
document.relative_path
|
||||
);
|
||||
const record = this.queue.getDocument(document.relative_path);
|
||||
|
||||
if (!record) {
|
||||
// the document of the cursor must be from the future
|
||||
|
|
@ -237,13 +229,11 @@ export class CursorTracker {
|
|||
}
|
||||
|
||||
if (
|
||||
(record.metadata?.parentVersionId ?? 0) <
|
||||
(document.vault_update_id ?? 0)
|
||||
record.parentVersionId < (document.vault_update_id ?? 0)
|
||||
) {
|
||||
return DocumentUpToDateness.Later;
|
||||
} else if (
|
||||
(document.vault_update_id ?? 0) <
|
||||
(record.metadata?.parentVersionId ?? 0)
|
||||
(document.vault_update_id ?? 0) < record.parentVersionId
|
||||
) {
|
||||
// the document of the cursor must be from the past
|
||||
return DocumentUpToDateness.Prior;
|
||||
|
|
@ -253,9 +243,8 @@ export class CursorTracker {
|
|||
document.relative_path
|
||||
);
|
||||
|
||||
return this.database.getLatestDocumentByRelativePath(
|
||||
document.relative_path
|
||||
)?.metadata?.hash === (await hash(currentContent))
|
||||
const currentRecord = this.queue.getDocument(document.relative_path);
|
||||
return currentRecord?.hash === (await hash(currentContent))
|
||||
? DocumentUpToDateness.UpToDate
|
||||
: DocumentUpToDateness.Prior;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,443 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { SyncEventQueue, type SyncEvent } from "./sync-event-queue";
|
||||
import { SyncEventQueue } from "./sync-event-queue";
|
||||
import { Settings } from "../persistence/settings";
|
||||
import { Logger } from "../tracing/logger";
|
||||
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
|
||||
import { SyncEventType } 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 () => {});
|
||||
}
|
||||
|
||||
function fakeRemoteVersion(
|
||||
documentId: string,
|
||||
overrides: Partial<DocumentVersionWithoutContent> = {}
|
||||
): DocumentVersionWithoutContent {
|
||||
return {
|
||||
vaultUpdateId: 1,
|
||||
documentId,
|
||||
relativePath: `${documentId}.md`,
|
||||
updatedDate: "2026-01-01",
|
||||
isDeleted: false,
|
||||
userId: "user",
|
||||
deviceId: "device",
|
||||
contentSize: 100,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe("SyncEventQueue", () => {
|
||||
it("delete collapses interleaved events for one document while leaving the other intact", () => {
|
||||
const queue = new SyncEventQueue();
|
||||
queue.enqueue({ type: "local-content-update", documentId: "A" });
|
||||
queue.enqueue({ type: "remote-content-update", documentId: "B" });
|
||||
queue.enqueue({ type: "local-content-update", documentId: "A" });
|
||||
queue.enqueue({ type: "move", documentId: "A" });
|
||||
queue.enqueue({ type: "remote-content-update", documentId: "A" });
|
||||
queue.enqueue({ type: "delete", documentId: "A" });
|
||||
queue.enqueue({ type: "local-content-update", documentId: "B" });
|
||||
|
||||
assert.deepStrictEqual(queue.next(), { type: "delete", documentId: "A" });
|
||||
assert.deepStrictEqual(queue.next(), {
|
||||
type: "local-content-update",
|
||||
documentId: "B"
|
||||
it("sync-local followed by delete for the same document returns only the delete", () => {
|
||||
const queue = createQueue();
|
||||
queue.setDocument("a.md", {
|
||||
documentId: "A",
|
||||
parentVersionId: 1,
|
||||
hash: "hash-a"
|
||||
});
|
||||
|
||||
queue.enqueue({ type: SyncEventType.SyncLocal, documentId: "A" });
|
||||
queue.enqueue({ type: SyncEventType.SyncLocal, documentId: "A" });
|
||||
queue.enqueue({
|
||||
type: SyncEventType.Delete,
|
||||
documentId: "A",
|
||||
path: "a.md",
|
||||
});
|
||||
|
||||
const event = queue.next();
|
||||
assert.strictEqual(event?.type, SyncEventType.Delete);
|
||||
if (event?.type === SyncEventType.Delete) {
|
||||
assert.strictEqual(event.documentId, "A");
|
||||
}
|
||||
assert.strictEqual(queue.next(), undefined);
|
||||
});
|
||||
|
||||
it("updates coalesce up to a move boundary then post-move events are processed separately", () => {
|
||||
const queue = new SyncEventQueue();
|
||||
queue.enqueue({ type: "local-content-update", documentId: "X" });
|
||||
queue.enqueue({ type: "remote-content-update", documentId: "X" });
|
||||
queue.enqueue({ type: "file-create", path: "new.md" });
|
||||
queue.enqueue({ type: "local-content-update", documentId: "X" });
|
||||
queue.enqueue({ type: "move", documentId: "X" });
|
||||
queue.enqueue({ type: "remote-content-update", documentId: "X" });
|
||||
queue.enqueue({ type: "local-content-update", documentId: "X" });
|
||||
it("sync-local events for the same document coalesce to one", () => {
|
||||
const queue = createQueue();
|
||||
queue.setDocument("a.md", {
|
||||
documentId: "A",
|
||||
parentVersionId: 1,
|
||||
hash: "hash-a"
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(queue.next(), {
|
||||
type: "local-content-update",
|
||||
documentId: "X"
|
||||
});
|
||||
assert.deepStrictEqual(queue.next(), { type: "file-create", path: "new.md" });
|
||||
assert.deepStrictEqual(queue.next(), { type: "move", documentId: "X" });
|
||||
assert.deepStrictEqual(queue.next(), {
|
||||
type: "local-content-update",
|
||||
documentId: "X"
|
||||
});
|
||||
queue.enqueue({ type: SyncEventType.SyncLocal, documentId: "A" });
|
||||
queue.enqueue({ type: SyncEventType.SyncLocal, documentId: "A" });
|
||||
queue.enqueue({ type: SyncEventType.SyncLocal, documentId: "A" });
|
||||
|
||||
const event = queue.next();
|
||||
assert.strictEqual(event?.type, SyncEventType.SyncLocal);
|
||||
assert.strictEqual(queue.next(), undefined);
|
||||
});
|
||||
|
||||
it("sync-remote events for the same documentId coalesce to the last one", () => {
|
||||
const queue = createQueue();
|
||||
|
||||
queue.enqueue({
|
||||
type: SyncEventType.SyncRemote,
|
||||
remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 1 })
|
||||
});
|
||||
queue.enqueue({
|
||||
type: SyncEventType.SyncRemote,
|
||||
remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 2 })
|
||||
});
|
||||
queue.enqueue({
|
||||
type: SyncEventType.SyncRemote,
|
||||
remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 3 })
|
||||
});
|
||||
|
||||
const event = queue.next();
|
||||
assert.strictEqual(event?.type, SyncEventType.SyncRemote);
|
||||
if (event?.type === SyncEventType.SyncRemote) {
|
||||
assert.strictEqual(event.remoteVersion.vaultUpdateId, 3);
|
||||
}
|
||||
assert.strictEqual(queue.next(), undefined);
|
||||
});
|
||||
|
||||
it("create events are returned FIFO", () => {
|
||||
const queue = createQueue();
|
||||
queue.enqueue({ type: SyncEventType.Create, path: "a.md" });
|
||||
queue.enqueue({ type: SyncEventType.Create, path: "b.md" });
|
||||
|
||||
const first = queue.next();
|
||||
assert.strictEqual(first?.type, SyncEventType.Create);
|
||||
if (first?.type === SyncEventType.Create) {
|
||||
assert.strictEqual(first.path, "a.md");
|
||||
}
|
||||
|
||||
const second = queue.next();
|
||||
assert.strictEqual(second?.type, SyncEventType.Create);
|
||||
if (second?.type === SyncEventType.Create) {
|
||||
assert.strictEqual(second.path, "b.md");
|
||||
}
|
||||
});
|
||||
|
||||
it("duplicate creates for the same path are skipped", () => {
|
||||
const queue = createQueue();
|
||||
queue.enqueue({ type: SyncEventType.Create, path: "a.md" });
|
||||
queue.enqueue({ type: SyncEventType.Create, path: "a.md" });
|
||||
assert.strictEqual(queue.size, 1);
|
||||
});
|
||||
|
||||
it("create is skipped if the path already has a tracked document", () => {
|
||||
const queue = createQueue();
|
||||
queue.setDocument("a.md", {
|
||||
documentId: "A",
|
||||
parentVersionId: 1,
|
||||
hash: "hash-a"
|
||||
});
|
||||
|
||||
queue.enqueue({ type: SyncEventType.Create, path: "a.md" });
|
||||
assert.strictEqual(queue.size, 0);
|
||||
});
|
||||
|
||||
it("delete uses the provided documentId", () => {
|
||||
const queue = createQueue();
|
||||
|
||||
queue.enqueue({
|
||||
type: SyncEventType.Delete,
|
||||
documentId: "A",
|
||||
path: "a.md",
|
||||
});
|
||||
|
||||
const event = queue.next();
|
||||
assert.strictEqual(event?.type, SyncEventType.Delete);
|
||||
if (event?.type === SyncEventType.Delete) {
|
||||
assert.strictEqual(event.documentId, "A");
|
||||
}
|
||||
});
|
||||
|
||||
it("updateCreatePath updates the path of a create event in the queue", () => {
|
||||
const queue = createQueue();
|
||||
queue.enqueue({ type: SyncEventType.Create, path: "old.md" });
|
||||
|
||||
const updated = queue.updateCreatePath("old.md", "new.md");
|
||||
assert.strictEqual(updated, true);
|
||||
assert.strictEqual(queue.hasCreateEvent("old.md"), false);
|
||||
assert.strictEqual(queue.hasCreateEvent("new.md"), true);
|
||||
|
||||
const event = queue.next();
|
||||
assert.strictEqual(event?.type, SyncEventType.Create);
|
||||
if (event?.type === SyncEventType.Create) {
|
||||
assert.strictEqual(event.path, "new.md");
|
||||
}
|
||||
});
|
||||
|
||||
it("updateCreatePath returns false when no create event exists", () => {
|
||||
const queue = createQueue();
|
||||
const updated = queue.updateCreatePath("old.md", "new.md");
|
||||
assert.strictEqual(updated, false);
|
||||
});
|
||||
|
||||
it("hasCreateEvent detects pending creates", () => {
|
||||
const queue = createQueue();
|
||||
assert.strictEqual(queue.hasCreateEvent("a.md"), false);
|
||||
|
||||
queue.enqueue({ type: SyncEventType.Create, path: "a.md" });
|
||||
assert.strictEqual(queue.hasCreateEvent("a.md"), true);
|
||||
|
||||
queue.next();
|
||||
assert.strictEqual(queue.hasCreateEvent("a.md"), false);
|
||||
});
|
||||
|
||||
it("document store CRUD operations work correctly", () => {
|
||||
const queue = createQueue();
|
||||
|
||||
assert.strictEqual(queue.getDocument("a.md"), undefined);
|
||||
assert.strictEqual(queue.documentCount, 0);
|
||||
|
||||
queue.setDocument("a.md", {
|
||||
documentId: "A",
|
||||
parentVersionId: 1,
|
||||
hash: "hash-a"
|
||||
});
|
||||
assert.strictEqual(queue.documentCount, 1);
|
||||
assert.deepStrictEqual(queue.getDocument("a.md"), {
|
||||
documentId: "A",
|
||||
parentVersionId: 1,
|
||||
hash: "hash-a"
|
||||
});
|
||||
|
||||
const found = queue.getDocumentByDocumentId("A");
|
||||
assert.strictEqual(found?.path, "a.md");
|
||||
assert.strictEqual(found?.record.documentId, "A");
|
||||
|
||||
queue.removeDocument("a.md");
|
||||
assert.strictEqual(queue.documentCount, 0);
|
||||
assert.strictEqual(queue.getDocument("a.md"), undefined);
|
||||
});
|
||||
|
||||
it("moveDocument moves a document and returns displaced documentId", () => {
|
||||
const queue = createQueue();
|
||||
queue.setDocument("a.md", {
|
||||
documentId: "A",
|
||||
parentVersionId: 1,
|
||||
hash: "hash-a"
|
||||
});
|
||||
queue.setDocument("b.md", {
|
||||
documentId: "B",
|
||||
parentVersionId: 2,
|
||||
hash: "hash-b"
|
||||
});
|
||||
|
||||
const displacedId = queue.moveDocument("a.md", "b.md");
|
||||
assert.strictEqual(displacedId, "B");
|
||||
assert.strictEqual(queue.getDocument("a.md"), undefined);
|
||||
assert.strictEqual(queue.getDocument("b.md")?.documentId, "A");
|
||||
assert.strictEqual(queue.documentCount, 1);
|
||||
});
|
||||
|
||||
it("moveDocument returns undefined when target is unoccupied", () => {
|
||||
const queue = createQueue();
|
||||
queue.setDocument("a.md", {
|
||||
documentId: "A",
|
||||
parentVersionId: 1,
|
||||
hash: "hash-a"
|
||||
});
|
||||
|
||||
const displacedId = queue.moveDocument("a.md", "b.md");
|
||||
assert.strictEqual(displacedId, undefined);
|
||||
assert.strictEqual(queue.getDocument("b.md")?.documentId, "A");
|
||||
});
|
||||
|
||||
it("interleaved events for different documents are not confused", () => {
|
||||
const queue = createQueue();
|
||||
queue.setDocument("a.md", {
|
||||
documentId: "A",
|
||||
parentVersionId: 1,
|
||||
hash: "hash-a"
|
||||
});
|
||||
queue.setDocument("b.md", {
|
||||
documentId: "B",
|
||||
parentVersionId: 2,
|
||||
hash: "hash-b"
|
||||
});
|
||||
|
||||
queue.enqueue({ type: SyncEventType.SyncLocal, documentId: "A" });
|
||||
queue.enqueue({ type: SyncEventType.SyncLocal, documentId: "B" });
|
||||
queue.enqueue({
|
||||
type: SyncEventType.Delete,
|
||||
documentId: "A",
|
||||
path: "a.md",
|
||||
});
|
||||
queue.enqueue({ type: SyncEventType.SyncLocal, documentId: "B" });
|
||||
|
||||
// First next() should see the delete for A (coalescing sync-local + delete)
|
||||
const first = queue.next();
|
||||
assert.strictEqual(first?.type, SyncEventType.Delete);
|
||||
if (first?.type === SyncEventType.Delete) {
|
||||
assert.strictEqual(first.documentId, "A");
|
||||
}
|
||||
|
||||
// Remaining should be the coalesced sync-local for B
|
||||
const second = queue.next();
|
||||
assert.strictEqual(second?.type, SyncEventType.SyncLocal);
|
||||
if (second?.type === SyncEventType.SyncLocal) {
|
||||
assert.strictEqual(second.documentId, "B");
|
||||
}
|
||||
|
||||
assert.strictEqual(queue.next(), undefined);
|
||||
});
|
||||
|
||||
it("delete discards subsequent sync-remote events for the same document", () => {
|
||||
const queue = createQueue();
|
||||
|
||||
queue.enqueue({
|
||||
type: SyncEventType.Delete,
|
||||
documentId: "A",
|
||||
path: "a.md",
|
||||
});
|
||||
queue.enqueue({
|
||||
type: SyncEventType.SyncRemote,
|
||||
remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 5 })
|
||||
});
|
||||
|
||||
const event = queue.next();
|
||||
assert.strictEqual(event?.type, SyncEventType.Delete);
|
||||
assert.strictEqual(queue.next(), undefined);
|
||||
});
|
||||
|
||||
it("delete discards subsequent sync-local and sync-remote for the same document", () => {
|
||||
const queue = createQueue();
|
||||
queue.setDocument("a.md", {
|
||||
documentId: "A",
|
||||
parentVersionId: 1,
|
||||
hash: "hash-a"
|
||||
});
|
||||
|
||||
queue.enqueue({
|
||||
type: SyncEventType.Delete,
|
||||
documentId: "A",
|
||||
path: "a.md",
|
||||
});
|
||||
queue.enqueue({ type: SyncEventType.SyncLocal, documentId: "A" });
|
||||
queue.enqueue({ type: SyncEventType.Create, path: "b.md" });
|
||||
queue.enqueue({
|
||||
type: SyncEventType.SyncRemote,
|
||||
remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 5 })
|
||||
});
|
||||
|
||||
const first = queue.next();
|
||||
assert.strictEqual(first?.type, SyncEventType.Delete);
|
||||
|
||||
// Only the unrelated create should remain
|
||||
const second = queue.next();
|
||||
assert.strictEqual(second?.type, SyncEventType.Create);
|
||||
assert.strictEqual(queue.next(), undefined);
|
||||
});
|
||||
|
||||
it("delete with empty documentId does not discard other events", () => {
|
||||
const queue = createQueue();
|
||||
queue.setDocument("a.md", {
|
||||
documentId: "A",
|
||||
parentVersionId: 1,
|
||||
hash: "hash-a"
|
||||
});
|
||||
|
||||
queue.enqueue({
|
||||
type: SyncEventType.Delete,
|
||||
documentId: "",
|
||||
path: "unknown.md",
|
||||
});
|
||||
queue.enqueue({ type: SyncEventType.SyncLocal, documentId: "A" });
|
||||
|
||||
queue.next();
|
||||
const second = queue.next();
|
||||
assert.strictEqual(second?.type, SyncEventType.SyncLocal);
|
||||
});
|
||||
|
||||
it("create can be re-enqueued after being dequeued", () => {
|
||||
const queue = createQueue();
|
||||
queue.enqueue({ type: SyncEventType.Create, path: "a.md" });
|
||||
queue.next();
|
||||
|
||||
queue.enqueue({ type: SyncEventType.Create, path: "a.md" });
|
||||
assert.strictEqual(queue.size, 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.Create, path: "notes-new.md" });
|
||||
assert.strictEqual(queue.size, 1);
|
||||
|
||||
queue.enqueue({
|
||||
type: SyncEventType.SyncRemote,
|
||||
remoteVersion: fakeRemoteVersion("N")
|
||||
});
|
||||
assert.strictEqual(queue.size, 2);
|
||||
});
|
||||
|
||||
it("clear removes events but keeps documents", () => {
|
||||
const queue = createQueue();
|
||||
queue.setDocument("a.md", {
|
||||
documentId: "A",
|
||||
parentVersionId: 1,
|
||||
hash: "hash-a"
|
||||
});
|
||||
queue.enqueue({ type: SyncEventType.Create, path: "b.md" });
|
||||
queue.enqueue({ type: SyncEventType.SyncLocal, documentId: "A" });
|
||||
|
||||
assert.strictEqual(queue.size, 2);
|
||||
|
||||
queue.clear();
|
||||
|
||||
assert.strictEqual(queue.size, 0);
|
||||
assert.strictEqual(queue.documentCount, 1);
|
||||
assert.strictEqual(queue.getDocument("a.md")?.documentId, "A");
|
||||
});
|
||||
|
||||
it("allDocuments returns all tracked documents", () => {
|
||||
const queue = createQueue();
|
||||
queue.setDocument("a.md", {
|
||||
documentId: "A",
|
||||
parentVersionId: 1,
|
||||
hash: "hash-a"
|
||||
});
|
||||
queue.setDocument("b.md", {
|
||||
documentId: "B",
|
||||
parentVersionId: 2,
|
||||
hash: "hash-b"
|
||||
});
|
||||
|
||||
const docs = queue.allDocuments();
|
||||
assert.strictEqual(docs.length, 2);
|
||||
const paths = docs.map(([p]) => p).sort();
|
||||
assert.deepStrictEqual(paths, ["a.md", "b.md"]);
|
||||
});
|
||||
|
||||
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",
|
||||
documentId: "A",
|
||||
parentVersionId: 5,
|
||||
hash: "hash-a"
|
||||
},
|
||||
{
|
||||
relativePath: "b.md",
|
||||
documentId: "B",
|
||||
parentVersionId: 3,
|
||||
hash: "hash-b"
|
||||
}
|
||||
],
|
||||
lastSeenUpdateId: 4
|
||||
}, async () => {});
|
||||
|
||||
assert.strictEqual(queue.documentCount, 2);
|
||||
assert.strictEqual(queue.getDocument("a.md")?.documentId, "A");
|
||||
assert.strictEqual(queue.getDocument("b.md")?.documentId, "B");
|
||||
assert.strictEqual(queue.getLastSeenUpdateId(), 5);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,85 +1,336 @@
|
|||
import type { DocumentId, RelativePath } from "../persistence/database";
|
||||
|
||||
export type SyncEvent =
|
||||
| { type: "file-create"; path: RelativePath }
|
||||
| { type: "local-content-update"; documentId: DocumentId }
|
||||
| { type: "remote-content-update"; documentId: DocumentId }
|
||||
| { type: "move"; documentId: DocumentId }
|
||||
| { type: "delete"; documentId: DocumentId };
|
||||
import type { Settings } from "../persistence/settings";
|
||||
import type { Logger } from "../tracing/logger";
|
||||
import { globsToRegexes } from "../utils/globs-to-regexes";
|
||||
import { CoveredValues } from "../utils/data-structures/min-covered";
|
||||
import { removeFromArray } from "../utils/remove-from-array";
|
||||
import {
|
||||
SyncEventType,
|
||||
type DocumentId,
|
||||
type DocumentRecord,
|
||||
type RelativePath,
|
||||
type StoredSyncState,
|
||||
type SyncEvent,
|
||||
type VaultUpdateId,
|
||||
} from "./types";
|
||||
|
||||
export class SyncEventQueue {
|
||||
private readonly events: SyncEvent[] = [];
|
||||
private readonly documents = new Map<RelativePath, DocumentRecord>();
|
||||
private readonly recentlyDeletedDocumentIds = new Set<DocumentId>();
|
||||
private lastSeenUpdateIds: CoveredValues;
|
||||
private ignorePatterns: RegExp[];
|
||||
|
||||
public constructor(
|
||||
private readonly settings: Settings,
|
||||
private readonly logger: Logger,
|
||||
initialState: Partial<StoredSyncState> | undefined,
|
||||
private readonly saveData: (data: StoredSyncState) => Promise<void>
|
||||
) {
|
||||
this.ignorePatterns = globsToRegexes(
|
||||
this.settings.getSettings().ignorePatterns,
|
||||
this.logger
|
||||
);
|
||||
|
||||
this.settings.onSettingsChanged.add((newSettings) => {
|
||||
this.ignorePatterns = globsToRegexes(
|
||||
newSettings.ignorePatterns,
|
||||
this.logger
|
||||
);
|
||||
});
|
||||
|
||||
initialState ??= {};
|
||||
|
||||
if (initialState.documents !== undefined) {
|
||||
for (const { relativePath, ...record } of initialState.documents) {
|
||||
this.documents.set(relativePath, record);
|
||||
}
|
||||
}
|
||||
|
||||
const { lastSeenUpdateId } = initialState;
|
||||
this.lastSeenUpdateIds = new CoveredValues(
|
||||
Math.max(0, lastSeenUpdateId ?? 0)
|
||||
);
|
||||
|
||||
for (const [, record] of this.documents) {
|
||||
this.lastSeenUpdateIds.add(record.parentVersionId);
|
||||
}
|
||||
|
||||
this.logger.debug(`Loaded ${this.documents.size} documents`);
|
||||
}
|
||||
|
||||
public get size(): number {
|
||||
return this.events.length;
|
||||
}
|
||||
|
||||
public get documentCount(): number {
|
||||
return this.documents.size;
|
||||
}
|
||||
|
||||
public getLastSeenUpdateId(): VaultUpdateId {
|
||||
return this.lastSeenUpdateIds.min;
|
||||
}
|
||||
|
||||
public addSeenUpdateId(value: number): void {
|
||||
const previousMin = this.lastSeenUpdateIds.min;
|
||||
this.lastSeenUpdateIds.add(value);
|
||||
if (previousMin !== this.lastSeenUpdateIds.min) {
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
}
|
||||
|
||||
public setLastSeenUpdateId(value: number): void {
|
||||
this.lastSeenUpdateIds.min = value;
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
|
||||
public getDocument(path: RelativePath): DocumentRecord | undefined {
|
||||
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 {
|
||||
const record = this.documents.get(path);
|
||||
if (record !== undefined) {
|
||||
this.recentlyDeletedDocumentIds.add(record.documentId);
|
||||
}
|
||||
this.documents.delete(path);
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a document from oldPath to newPath.
|
||||
* If the target path is occupied by a different document, it is removed
|
||||
* and its documentId is returned so the caller can handle the displacement.
|
||||
*/
|
||||
public moveDocument(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): DocumentId | undefined {
|
||||
const record = this.documents.get(oldPath);
|
||||
if (record === undefined) return undefined;
|
||||
|
||||
let displacedDocumentId: DocumentId | undefined = undefined;
|
||||
const existingAtTarget = this.documents.get(newPath);
|
||||
if (
|
||||
existingAtTarget !== undefined &&
|
||||
existingAtTarget.documentId !== record.documentId
|
||||
) {
|
||||
displacedDocumentId = existingAtTarget.documentId;
|
||||
this.recentlyDeletedDocumentIds.add(displacedDocumentId);
|
||||
this.documents.delete(newPath);
|
||||
}
|
||||
|
||||
this.documents.delete(oldPath);
|
||||
this.documents.set(newPath, record);
|
||||
this.saveInTheBackground();
|
||||
return displacedDocumentId;
|
||||
}
|
||||
|
||||
public wasRecentlyDeleted(documentId: DocumentId): boolean {
|
||||
return this.recentlyDeletedDocumentIds.has(documentId);
|
||||
}
|
||||
|
||||
public unmarkRecentlyDeleted(documentId: DocumentId): void {
|
||||
this.recentlyDeletedDocumentIds.delete(documentId);
|
||||
}
|
||||
|
||||
|
||||
public allDocuments(): [RelativePath, DocumentRecord][] {
|
||||
return Array.from(this.documents.entries());
|
||||
}
|
||||
|
||||
public hasCreateEvent(path: RelativePath): boolean {
|
||||
return this.events.some(
|
||||
(e) => e.type === SyncEventType.Create && e.path === path
|
||||
);
|
||||
}
|
||||
|
||||
public updateCreatePath(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): boolean {
|
||||
for (const event of this.events) {
|
||||
if (event.type === SyncEventType.Create && event.path === oldPath) {
|
||||
event.path = newPath;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public hasPendingEventsForPath(path: RelativePath): boolean {
|
||||
const record = this.documents.get(path);
|
||||
const docId = record?.documentId;
|
||||
return this.events.some(
|
||||
(e) =>
|
||||
(e.type === SyncEventType.Create && e.path === path) ||
|
||||
(e.type === SyncEventType.SyncLocal &&
|
||||
docId !== undefined &&
|
||||
e.documentId === docId) ||
|
||||
(e.type === SyncEventType.Delete &&
|
||||
docId !== undefined &&
|
||||
e.documentId === docId) ||
|
||||
(e.type === SyncEventType.SyncRemote &&
|
||||
e.remoteVersion.relativePath === path)
|
||||
);
|
||||
}
|
||||
|
||||
public async save(): Promise<void> {
|
||||
return this.saveData({
|
||||
documents: Array.from(this.documents.entries()).map(
|
||||
([relativePath, record]) => ({
|
||||
relativePath,
|
||||
...record
|
||||
})
|
||||
),
|
||||
lastSeenUpdateId: this.lastSeenUpdateIds.min
|
||||
});
|
||||
}
|
||||
|
||||
public resetState(): void {
|
||||
this.documents.clear();
|
||||
this.recentlyDeletedDocumentIds.clear();
|
||||
this.lastSeenUpdateIds = new CoveredValues(0);
|
||||
this.saveInTheBackground();
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.events.length = 0;
|
||||
this.recentlyDeletedDocumentIds.clear();
|
||||
}
|
||||
|
||||
public enqueue(event: SyncEvent): void {
|
||||
if (this.isIgnored(event)) return;
|
||||
|
||||
if (event.type === SyncEventType.Create) {
|
||||
if (this.documents.has(event.path)) return;
|
||||
if (this.hasCreateEvent(event.path)) return;
|
||||
}
|
||||
|
||||
this.events.push(event);
|
||||
}
|
||||
|
||||
|
||||
|
||||
public next(): SyncEvent | undefined {
|
||||
if (this.events.length === 0) return undefined;
|
||||
|
||||
const first = this.events[0];
|
||||
if (first.type === "file-create") {
|
||||
const [first] = this.events;
|
||||
|
||||
// Creates are always returned immediately (FIFO)
|
||||
if (first.type === SyncEventType.Create) {
|
||||
this.events.shift();
|
||||
return first;
|
||||
}
|
||||
|
||||
const { documentId } = first;
|
||||
|
||||
// If there's an eventual delete, discard everything for this document
|
||||
const deleteEvent = this.events.find(
|
||||
(e) => e.type === "delete" && e.documentId === documentId
|
||||
);
|
||||
if (deleteEvent) {
|
||||
this.removeAllForDocument(documentId);
|
||||
return deleteEvent;
|
||||
}
|
||||
|
||||
// Coalesce updates: return the last update before the next move for this document.
|
||||
// Moves act as barriers since they depend on each other
|
||||
const moveIndex = this.events.findIndex(
|
||||
(e) => e.type === "move" && e.documentId === documentId
|
||||
);
|
||||
const boundary = moveIndex === -1 ? this.events.length : moveIndex;
|
||||
|
||||
const updateIndices: number[] = [];
|
||||
for (let i = 0; i < boundary; i++) {
|
||||
const e = this.events[i];
|
||||
if (
|
||||
(e.type === "local-content-update" ||
|
||||
e.type === "remote-content-update") &&
|
||||
e.documentId === documentId
|
||||
) {
|
||||
updateIndices.push(i);
|
||||
// Deletes are returned immediately; also discard any subsequent
|
||||
// events for the same documentId so stale broadcasts don't
|
||||
// resurrect the document
|
||||
if (first.type === SyncEventType.Delete) {
|
||||
this.events.shift();
|
||||
const { documentId } = first;
|
||||
if (documentId !== "") {
|
||||
this.removeAllEventsForDocumentId(documentId);
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
if (updateIndices.length > 0) {
|
||||
const result = this.events[updateIndices[updateIndices.length - 1]];
|
||||
for (let i = updateIndices.length - 1; i >= 0; i--) {
|
||||
this.events.splice(updateIndices[i], 1);
|
||||
if (first.type === SyncEventType.SyncLocal) {
|
||||
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.documentId === documentId
|
||||
);
|
||||
if (deleteEvent !== undefined) {
|
||||
this.removeAllSyncLocalsForDocumentId(documentId);
|
||||
removeFromArray(this.events, deleteEvent);
|
||||
return deleteEvent;
|
||||
}
|
||||
|
||||
// Coalesce multiple sync-locals for the same documentId to the last one
|
||||
const matching = this.events.filter(
|
||||
(e) =>
|
||||
e.type === SyncEventType.SyncLocal &&
|
||||
e.documentId === documentId
|
||||
);
|
||||
const result = matching[matching.length - 1];
|
||||
for (const item of matching) {
|
||||
removeFromArray(this.events, item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// First event is a move with no preceding updates
|
||||
this.events.shift();
|
||||
return first;
|
||||
// SyncRemote: coalesce multiple events for the same documentId to the last one
|
||||
const { documentId } = first.remoteVersion;
|
||||
const matching = this.events.filter(
|
||||
(e) =>
|
||||
e.type === SyncEventType.SyncRemote &&
|
||||
e.remoteVersion.documentId === documentId
|
||||
);
|
||||
const result = matching[matching.length - 1];
|
||||
for (const item of matching) {
|
||||
removeFromArray(this.events, item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private removeAllForDocument(documentId: DocumentId): void {
|
||||
private isIgnored(event: SyncEvent): boolean {
|
||||
if (event.type !== SyncEventType.Create) return false;
|
||||
return this.ignorePatterns.some((pattern) => pattern.test(event.path));
|
||||
}
|
||||
|
||||
private removeAllEventsForDocumentId(documentId: DocumentId): void {
|
||||
for (let i = this.events.length - 1; i >= 0; i--) {
|
||||
const e = this.events[i];
|
||||
if (e.type !== "file-create" && e.documentId === documentId) {
|
||||
if (
|
||||
(e.type === SyncEventType.SyncLocal &&
|
||||
e.documentId === documentId) ||
|
||||
(e.type === SyncEventType.SyncRemote &&
|
||||
e.remoteVersion.documentId === documentId) ||
|
||||
(e.type === SyncEventType.Delete &&
|
||||
e.documentId === documentId)
|
||||
) {
|
||||
// eslint-disable-next-line no-restricted-syntax -- Bulk removal by predicate, not single-item removal
|
||||
this.events.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private removeAllSyncLocalsForDocumentId(documentId: DocumentId): void {
|
||||
for (let i = this.events.length - 1; i >= 0; i--) {
|
||||
const e = this.events[i];
|
||||
if (
|
||||
e.type === SyncEventType.SyncLocal &&
|
||||
e.documentId === documentId
|
||||
) {
|
||||
// eslint-disable-next-line no-restricted-syntax -- Bulk removal by predicate, not single-item removal
|
||||
this.events.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private saveInTheBackground(): void {
|
||||
void this.save().catch((error: unknown) => {
|
||||
this.logger.error(`Error saving sync state: ${error}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
42
frontend/sync-client/src/sync-operations/types.ts
Normal file
42
frontend/sync-client/src/sync-operations/types.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
|
||||
|
||||
export type VaultUpdateId = number;
|
||||
export type DocumentId = string;
|
||||
export type RelativePath = string;
|
||||
|
||||
export interface DocumentRecord {
|
||||
documentId: DocumentId;
|
||||
parentVersionId: VaultUpdateId;
|
||||
hash: string;
|
||||
remoteRelativePath?: RelativePath;
|
||||
}
|
||||
|
||||
export interface StoredDocument extends DocumentRecord {
|
||||
relativePath: RelativePath;
|
||||
}
|
||||
|
||||
export interface StoredSyncState {
|
||||
documents: StoredDocument[];
|
||||
lastSeenUpdateId: VaultUpdateId | undefined;
|
||||
}
|
||||
|
||||
export enum SyncEventType {
|
||||
Create = "create",
|
||||
SyncLocal = "sync-local",
|
||||
SyncRemote = "sync-remote",
|
||||
Delete = "delete",
|
||||
}
|
||||
|
||||
export type SyncEvent =
|
||||
| { type: SyncEventType.Create; path: RelativePath }
|
||||
| { type: SyncEventType.SyncLocal; documentId: DocumentId }
|
||||
| {
|
||||
type: SyncEventType.Delete;
|
||||
documentId: DocumentId;
|
||||
path: RelativePath;
|
||||
displacedAtVersion?: VaultUpdateId;
|
||||
}
|
||||
| {
|
||||
type: SyncEventType.SyncRemote;
|
||||
remoteVersion: DocumentVersionWithoutContent;
|
||||
};
|
||||
|
|
@ -1,612 +0,0 @@
|
|||
import type {
|
||||
Database,
|
||||
DocumentRecord,
|
||||
RelativePath
|
||||
} from "../persistence/database";
|
||||
import { diff } from "reconcile-text";
|
||||
import type { SyncService } from "../services/sync-service";
|
||||
import type { Logger } from "../tracing/logger";
|
||||
import type {
|
||||
CommonHistoryEntry,
|
||||
SyncCreateDetails,
|
||||
SyncDeleteDetails,
|
||||
SyncDetails,
|
||||
SyncHistory,
|
||||
SyncMovedDetails,
|
||||
SyncUpdateDetails
|
||||
} from "../tracing/sync-history";
|
||||
import { SyncStatus, SyncType } from "../tracing/sync-history";
|
||||
import { EMPTY_HASH, hash } from "../utils/hash";
|
||||
import { base64ToBytes } from "byte-base64";
|
||||
import type { Settings } from "../persistence/settings";
|
||||
import type { FileOperations } from "../file-operations/file-operations";
|
||||
import { FileNotFoundError } from "../errors/file-not-found-error";
|
||||
import { SyncResetError } from "../errors/sync-reset-error";
|
||||
import { globsToRegexes } from "../utils/globs-to-regexes";
|
||||
import type { DocumentVersion } from "../services/types/DocumentVersion";
|
||||
import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse";
|
||||
import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent";
|
||||
import type { FixedSizeDocumentCache } from "../utils/data-structures/fix-sized-cache";
|
||||
import { isFileTypeMergable } from "../utils/is-file-type-mergable";
|
||||
import { isBinary } from "../utils/is-binary";
|
||||
import type { ServerConfig } from "../services/server-config";
|
||||
|
||||
export class UnrestrictedSyncer {
|
||||
private ignorePatterns: RegExp[];
|
||||
|
||||
public constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly database: Database,
|
||||
private readonly settings: Settings,
|
||||
private readonly syncService: SyncService,
|
||||
private readonly operations: FileOperations,
|
||||
private readonly history: SyncHistory,
|
||||
private readonly contentCache: FixedSizeDocumentCache,
|
||||
private readonly serverConfig: ServerConfig
|
||||
) {
|
||||
this.ignorePatterns = globsToRegexes(
|
||||
this.settings.getSettings().ignorePatterns,
|
||||
this.logger
|
||||
);
|
||||
|
||||
this.settings.onSettingsChanged.add((newSettings) => {
|
||||
this.ignorePatterns = globsToRegexes(
|
||||
newSettings.ignorePatterns,
|
||||
this.logger
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public async unrestrictedSyncLocallyCreatedOrUpdatedFile({
|
||||
oldPath,
|
||||
// We use the same code path for both local and remote updates. We need to force the update
|
||||
// if there are no local changes but we know that the remote version is newer.
|
||||
force = false,
|
||||
document
|
||||
}: {
|
||||
oldPath?: RelativePath;
|
||||
force?: boolean;
|
||||
document: DocumentRecord;
|
||||
}): Promise<void> {
|
||||
const updateDetails:
|
||||
| SyncCreateDetails
|
||||
| SyncUpdateDetails
|
||||
| SyncMovedDetails =
|
||||
document.metadata === undefined
|
||||
? {
|
||||
type: SyncType.CREATE,
|
||||
relativePath: document.relativePath
|
||||
}
|
||||
: oldPath !== undefined
|
||||
? {
|
||||
type: SyncType.MOVE,
|
||||
relativePath: document.relativePath,
|
||||
movedFrom: oldPath
|
||||
}
|
||||
: {
|
||||
type: SyncType.UPDATE,
|
||||
relativePath: document.relativePath
|
||||
};
|
||||
|
||||
await this.executeSync(updateDetails, async () => {
|
||||
const originalRelativePath = document.relativePath;
|
||||
|
||||
if (document.isDeleted) {
|
||||
this.logger.debug(
|
||||
`Document ${document.relativePath} has been already deleted, no need to update it`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const contentBytes = await this.operations.read(
|
||||
document.relativePath
|
||||
); // this can throw FileNotFoundError
|
||||
const contentHash = await hash(contentBytes);
|
||||
|
||||
let response: DocumentVersion | DocumentUpdateResponse | undefined =
|
||||
undefined;
|
||||
if (document.metadata === undefined) {
|
||||
response = await this.syncService.create({
|
||||
relativePath: originalRelativePath,
|
||||
contentBytes
|
||||
});
|
||||
|
||||
await this.handleMaybeMergingResponse({
|
||||
document,
|
||||
response,
|
||||
contentHash,
|
||||
originalRelativePath,
|
||||
originalContentBytes: contentBytes,
|
||||
isCreate: true
|
||||
});
|
||||
} else {
|
||||
const areThereLocalChanges =
|
||||
document.metadata.hash !== contentHash ||
|
||||
oldPath !== undefined;
|
||||
|
||||
if (areThereLocalChanges) {
|
||||
const isText =
|
||||
!isBinary(contentBytes) &&
|
||||
isFileTypeMergable(
|
||||
document.relativePath,
|
||||
(await this.serverConfig.getConfig())
|
||||
.mergeableFileExtensions
|
||||
);
|
||||
const cachedVersion = this.contentCache.get(
|
||||
document.metadata.parentVersionId
|
||||
);
|
||||
|
||||
response =
|
||||
isText && cachedVersion !== undefined
|
||||
? await this.syncService.putText({
|
||||
documentId: document.metadata.documentId,
|
||||
parentVersionId:
|
||||
document.metadata.parentVersionId,
|
||||
relativePath: document.relativePath,
|
||||
content: diff(
|
||||
new TextDecoder().decode(cachedVersion),
|
||||
new TextDecoder().decode(contentBytes)
|
||||
)
|
||||
})
|
||||
: await this.syncService.putBinary({
|
||||
documentId: document.metadata.documentId,
|
||||
parentVersionId:
|
||||
document.metadata.parentVersionId,
|
||||
relativePath: document.relativePath,
|
||||
contentBytes
|
||||
});
|
||||
} else {
|
||||
if (!force) {
|
||||
this.logger.debug(
|
||||
`File hash of ${document.relativePath} matches with last synced version and the path hasn't changed; no need to sync`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// we use this code path (force == true) to sync remotely updated files which have no local changes
|
||||
response = await this.syncService.get({
|
||||
documentId: document.metadata.documentId
|
||||
});
|
||||
}
|
||||
|
||||
await this.handleMaybeMergingResponse({
|
||||
document,
|
||||
response,
|
||||
contentHash,
|
||||
originalRelativePath,
|
||||
originalContentBytes: contentBytes
|
||||
});
|
||||
}
|
||||
|
||||
if (!("type" in response) || response.type === "MergingUpdate") {
|
||||
if (!force) {
|
||||
this.history.addHistoryEntry({
|
||||
status: SyncStatus.SUCCESS,
|
||||
details: updateDetails,
|
||||
message: `The file we updated had been updated remotely, so we downloaded the merged version`
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const actualUpdateDetails: SyncUpdateDetails | SyncMovedDetails =
|
||||
oldPath !== undefined ||
|
||||
response.relativePath != originalRelativePath
|
||||
? {
|
||||
type: SyncType.MOVE,
|
||||
relativePath: response.relativePath,
|
||||
movedFrom: originalRelativePath
|
||||
}
|
||||
: {
|
||||
type: SyncType.UPDATE,
|
||||
relativePath: response.relativePath
|
||||
};
|
||||
|
||||
if (!response.isDeleted) {
|
||||
this.history.addHistoryEntry({
|
||||
status: SyncStatus.SUCCESS,
|
||||
details: actualUpdateDetails,
|
||||
message: `Successfully downloaded remotely updated file from the server`,
|
||||
author: response.userId,
|
||||
timestamp: new Date(response.updatedDate)
|
||||
});
|
||||
} else {
|
||||
this.history.addHistoryEntry({
|
||||
status: SyncStatus.SUCCESS,
|
||||
details: {
|
||||
type: SyncType.DELETE,
|
||||
relativePath: document.relativePath
|
||||
},
|
||||
message:
|
||||
"Successfully deleted file which had been deleted remotely",
|
||||
author: response.userId,
|
||||
timestamp: new Date(response.updatedDate)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async unrestrictedSyncLocallyDeletedFile(
|
||||
document: DocumentRecord
|
||||
): Promise<void> {
|
||||
const updateDetails: SyncDeleteDetails = {
|
||||
type: SyncType.DELETE,
|
||||
relativePath: document.relativePath
|
||||
};
|
||||
|
||||
await this.executeSync(updateDetails, async () => {
|
||||
if (document.metadata === undefined) {
|
||||
this.logger.debug(
|
||||
`Document ${document.relativePath} has never been synced, no need to delete it remotely`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.syncService.delete({
|
||||
documentId: document.metadata.documentId,
|
||||
relativePath: document.relativePath
|
||||
});
|
||||
|
||||
this.database.updateDocumentMetadata(
|
||||
{
|
||||
documentId: response.documentId,
|
||||
parentVersionId: response.vaultUpdateId,
|
||||
hash: EMPTY_HASH,
|
||||
remoteRelativePath: document.relativePath
|
||||
},
|
||||
document
|
||||
);
|
||||
|
||||
this.database.addSeenUpdateId(response.vaultUpdateId);
|
||||
|
||||
this.history.addHistoryEntry({
|
||||
status: SyncStatus.SUCCESS,
|
||||
details: updateDetails,
|
||||
message: `Successfully deleted locally deleted file on the server`,
|
||||
author: response.userId
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async unrestrictedSyncRemotelyUpdatedFile(
|
||||
remoteVersion: DocumentVersionWithoutContent,
|
||||
document?: DocumentRecord
|
||||
): Promise<void> {
|
||||
const updateDetails: SyncCreateDetails = {
|
||||
type: SyncType.CREATE,
|
||||
relativePath: remoteVersion.relativePath
|
||||
};
|
||||
|
||||
await this.executeSync(updateDetails, async () => {
|
||||
if (document?.metadata !== undefined) {
|
||||
// If the file exists locally, let's pretend the user has updated it
|
||||
// and deal with remote update/deletion within `unrestrictedSyncLocallyUpdatedFile`
|
||||
if (
|
||||
document.metadata.parentVersionId >=
|
||||
remoteVersion.vaultUpdateId
|
||||
) {
|
||||
this.logger.debug(
|
||||
`Document ${document.relativePath} is already at least as up-to-date as the fetched version`
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return this.unrestrictedSyncLocallyCreatedOrUpdatedFile({
|
||||
document,
|
||||
force: true
|
||||
});
|
||||
} else if (remoteVersion.isDeleted) {
|
||||
// Either the document hasn't made it to us before and therefore we don't need to delete it,
|
||||
// or we already have it, in which case the preceeding if would've dealt with it
|
||||
this.logger.debug(
|
||||
`Document ${remoteVersion.relativePath} has been deleted remotely, no need to sync`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't download oversized files
|
||||
const historyEntryForSkippedOversizedFile =
|
||||
this.getHistoryEntryForSkippedOversizedFile(
|
||||
remoteVersion.contentSize,
|
||||
remoteVersion.relativePath
|
||||
);
|
||||
if (historyEntryForSkippedOversizedFile !== undefined) {
|
||||
this.history.addHistoryEntry(
|
||||
historyEntryForSkippedOversizedFile
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const contentBytes =
|
||||
await this.syncService.getDocumentVersionContent({
|
||||
documentId: remoteVersion.documentId,
|
||||
vaultUpdateId: remoteVersion.vaultUpdateId
|
||||
});
|
||||
|
||||
// We're trying to create an entirely new document that didn't exist locally
|
||||
document = this.database.getDocumentByDocumentId(
|
||||
remoteVersion.documentId
|
||||
);
|
||||
// It can happen that a concurrent sync operation has already created the document, so we can bail here
|
||||
if (document !== undefined) {
|
||||
this.logger.debug(
|
||||
`Document ${remoteVersion.relativePath} has already been created locally, no need to create it again`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.operations.ensureClearPath(remoteVersion.relativePath);
|
||||
|
||||
this.database.updateDocumentMetadata(
|
||||
{
|
||||
documentId: remoteVersion.documentId,
|
||||
parentVersionId: remoteVersion.vaultUpdateId,
|
||||
hash: await hash(contentBytes),
|
||||
remoteRelativePath: remoteVersion.relativePath
|
||||
},
|
||||
this.database.createNewPendingDocument(
|
||||
remoteVersion.relativePath
|
||||
)
|
||||
);
|
||||
|
||||
await this.operations.create(
|
||||
remoteVersion.relativePath,
|
||||
contentBytes
|
||||
);
|
||||
await this.updateCache(
|
||||
remoteVersion.vaultUpdateId,
|
||||
contentBytes,
|
||||
remoteVersion.relativePath
|
||||
);
|
||||
|
||||
this.history.addHistoryEntry({
|
||||
status: SyncStatus.SUCCESS,
|
||||
details: updateDetails,
|
||||
message: `Successfully downloaded remote file which hadn't existed locally`,
|
||||
author: remoteVersion.userId,
|
||||
timestamp: new Date(remoteVersion.updatedDate)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async executeSync<T>(
|
||||
details: SyncDetails,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T | undefined> {
|
||||
if (!this.settings.getSettings().isSyncEnabled) {
|
||||
this.logger.info(
|
||||
`Skipping sync operation for file '${details.relativePath}' because sync is disabled`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const pattern of this.ignorePatterns) {
|
||||
if (pattern.test(details.relativePath)) {
|
||||
this.logger.debug(
|
||||
`File '${details.relativePath}' is ignored by the ignore pattern: ${pattern}`
|
||||
);
|
||||
return; // bail without SKIPPED status because we were told to ignore this file and we shouldn't clutter up the history
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Only check the size of files which already exist locally.
|
||||
if (await this.operations.exists(details.relativePath)) {
|
||||
const sizeInBytes = await this.operations.getFileSize(
|
||||
details.relativePath
|
||||
);
|
||||
const historyEntryForSkippedOversizedFile =
|
||||
this.getHistoryEntryForSkippedOversizedFile(
|
||||
sizeInBytes,
|
||||
details.relativePath
|
||||
);
|
||||
if (historyEntryForSkippedOversizedFile !== undefined) {
|
||||
this.history.addHistoryEntry(
|
||||
historyEntryForSkippedOversizedFile
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
if (e instanceof FileNotFoundError) {
|
||||
// A subsequent sync operation must have been creating to deal with this
|
||||
this.logger.info(
|
||||
`Skiping file '${details.relativePath}' because it no longer exists when trying to ${details.type.toLocaleLowerCase()} it`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e instanceof SyncResetError) {
|
||||
this.logger.info(
|
||||
`Interrupting sync operation because of a reset`
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
this.history.addHistoryEntry({
|
||||
status: SyncStatus.ERROR,
|
||||
details,
|
||||
message: `Failed to sync file '${details.relativePath}' because of ${e} when trying to ${details.type.toLocaleLowerCase()} it`
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMaybeMergingResponse({
|
||||
document,
|
||||
response,
|
||||
contentHash,
|
||||
originalRelativePath,
|
||||
originalContentBytes,
|
||||
isCreate
|
||||
}: {
|
||||
document: DocumentRecord;
|
||||
response: DocumentVersion | DocumentUpdateResponse;
|
||||
contentHash: string;
|
||||
originalRelativePath: string;
|
||||
originalContentBytes: Uint8Array;
|
||||
isCreate?: boolean;
|
||||
}): Promise<void> {
|
||||
// `document` is mutable and reflects the latest state in the local database
|
||||
if (document.isDeleted) {
|
||||
this.logger.info(
|
||||
`Document ${document.relativePath} has been deleted before we could finish updating it`
|
||||
);
|
||||
this.database.addSeenUpdateId(response.vaultUpdateId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(document.metadata?.parentVersionId ?? 0) > response.vaultUpdateId
|
||||
) {
|
||||
this.logger.debug(
|
||||
`Document ${document.relativePath} is already more up to date than the fetched version`
|
||||
);
|
||||
this.database.addSeenUpdateId(response.vaultUpdateId); // in case the previous `vaultUpdateId` update hasn't made it through
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.isDeleted) {
|
||||
return this.applyRemoteDeleteLocally(document, response);
|
||||
}
|
||||
|
||||
let actualPath = document.relativePath;
|
||||
|
||||
if (isCreate) {
|
||||
// We have a file locally that got moved by another client to the same path as the one we're trying to create.
|
||||
// The server returns a merging update for the document ID that already exists locally (but at another path).
|
||||
// We have to merge these two documents by extending the provenance of the existing document and deleting
|
||||
// the old document that the new document already contains the content for.
|
||||
const existingDocument = this.database.getDocumentByDocumentId(
|
||||
response.documentId
|
||||
);
|
||||
if (existingDocument !== undefined) {
|
||||
this.logger.info(
|
||||
`Merging existing document ${existingDocument.relativePath} into ${document.relativePath
|
||||
} after concurrent move & creation`
|
||||
);
|
||||
if (!existingDocument.isDeleted) {
|
||||
this.database.delete(existingDocument.relativePath); // make sure syncLocallyDeletedFile doesn't actually schedule deleting the new file
|
||||
this.database.removeDocument(existingDocument);
|
||||
await this.operations.move(existingDocument.relativePath, document.relativePath);
|
||||
} else {
|
||||
this.database.removeDocument(existingDocument);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// this can't happen on the creation path as we can only get a merging response if a document already exists remotely on the same path
|
||||
if (response.relativePath != originalRelativePath) {
|
||||
actualPath = response.relativePath;
|
||||
// Make sure to update the remote relative path to avoid uploading
|
||||
// the file as a result of this filesystem event.
|
||||
if (document.metadata !== undefined) {
|
||||
document.metadata.remoteRelativePath = response.relativePath;
|
||||
}
|
||||
await this.operations.move(
|
||||
document.relativePath,
|
||||
response.relativePath
|
||||
); // this can throw FileNotFoundError
|
||||
}
|
||||
|
||||
if (!("type" in response) || response.type === "MergingUpdate") {
|
||||
const responseBytes = base64ToBytes(response.contentBase64);
|
||||
contentHash = await hash(responseBytes);
|
||||
|
||||
this.database.updateDocumentMetadata(
|
||||
{
|
||||
documentId: response.documentId,
|
||||
parentVersionId: response.vaultUpdateId,
|
||||
hash: contentHash,
|
||||
remoteRelativePath: response.relativePath
|
||||
},
|
||||
document
|
||||
);
|
||||
|
||||
await this.operations.write(
|
||||
actualPath,
|
||||
originalContentBytes,
|
||||
responseBytes
|
||||
);
|
||||
await this.updateCache(
|
||||
response.vaultUpdateId,
|
||||
responseBytes,
|
||||
actualPath
|
||||
);
|
||||
} else {
|
||||
this.database.updateDocumentMetadata(
|
||||
{
|
||||
documentId: response.documentId,
|
||||
parentVersionId: response.vaultUpdateId,
|
||||
hash: contentHash,
|
||||
remoteRelativePath: response.relativePath
|
||||
},
|
||||
document
|
||||
);
|
||||
await this.updateCache(
|
||||
response.vaultUpdateId,
|
||||
originalContentBytes,
|
||||
actualPath
|
||||
);
|
||||
}
|
||||
|
||||
this.database.addSeenUpdateId(response.vaultUpdateId);
|
||||
}
|
||||
|
||||
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,
|
||||
relativePath
|
||||
},
|
||||
message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${maxFileSizeMB
|
||||
} MB`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async updateCache(
|
||||
updateId: number,
|
||||
contentBytes: Uint8Array,
|
||||
filePath: RelativePath
|
||||
): Promise<void> {
|
||||
if (
|
||||
isFileTypeMergable(
|
||||
filePath,
|
||||
(await this.serverConfig.getConfig()).mergeableFileExtensions
|
||||
) &&
|
||||
!isBinary(contentBytes)
|
||||
) {
|
||||
this.contentCache.put(updateId, contentBytes);
|
||||
}
|
||||
}
|
||||
|
||||
private async applyRemoteDeleteLocally(
|
||||
document: DocumentRecord,
|
||||
response: DocumentVersion | DocumentUpdateResponse
|
||||
): Promise<void> {
|
||||
this.database.delete(document.relativePath);
|
||||
this.database.updateDocumentMetadata(
|
||||
{
|
||||
documentId: response.documentId,
|
||||
parentVersionId: response.vaultUpdateId,
|
||||
hash: EMPTY_HASH,
|
||||
remoteRelativePath: response.relativePath
|
||||
},
|
||||
document
|
||||
);
|
||||
|
||||
await this.operations.delete(document.relativePath);
|
||||
|
||||
this.database.addSeenUpdateId(response.vaultUpdateId);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue