Fix tests
This commit is contained in:
parent
081e35be5c
commit
fefac224b0
4 changed files with 161 additions and 400 deletions
|
|
@ -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<ServerConfig, "getConfig"> {
|
|||
}
|
||||
}
|
||||
|
||||
// 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<SyncEventQueue, "moveDocument" | "removeDocument"> {
|
||||
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<string>();
|
||||
|
||||
|
|
@ -62,14 +45,14 @@ class FakeFileSystemOperations implements FileSystemOperations {
|
|||
public async getFileSize(_path: RelativePath): Promise<number> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async getModificationTime(_path: RelativePath): Promise<Date> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async exists(path: RelativePath): Promise<boolean> {
|
||||
return this.names.has(path);
|
||||
}
|
||||
public async delete(_path: RelativePath): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
public async createDirectory(_path: RelativePath): Promise<void> {
|
||||
// no-op for the in-memory fake; we only track files
|
||||
}
|
||||
public async delete(path: RelativePath): Promise<void> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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> = {}
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue