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 }); }