Clean up
This commit is contained in:
parent
af500ddd0d
commit
935387c769
4 changed files with 54 additions and 409 deletions
|
|
@ -1,235 +0,0 @@
|
|||
import { describe, it } from "node:test";
|
||||
import type {
|
||||
Database,
|
||||
DocumentRecord,
|
||||
RelativePath
|
||||
} from "../persistence/database";
|
||||
import { FileOperations } from "./file-operations";
|
||||
import { Logger } from "../tracing/logger";
|
||||
import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly";
|
||||
import type { FileSystemOperations } from "./filesystem-operations";
|
||||
import type { TextWithCursors } from "reconcile-text";
|
||||
import type { ServerConfig, ServerConfigData } from "../services/server-config";
|
||||
|
||||
class MockServerConfig implements Pick<ServerConfig, "getConfig"> {
|
||||
public async getConfig(): Promise<ServerConfigData> {
|
||||
return {
|
||||
mergeableFileExtensions: ["md", "txt"],
|
||||
supportedApiVersion: 1,
|
||||
isAuthenticated: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MockDatabase implements Partial<Database> {
|
||||
public getLatestDocumentByRelativePath(
|
||||
_find: RelativePath
|
||||
): DocumentRecord | undefined {
|
||||
// no-op
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public move(
|
||||
_oldRelativePath: RelativePath,
|
||||
_newRelativePath: RelativePath
|
||||
): void {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
class FakeFileSystemOperations implements FileSystemOperations {
|
||||
public readonly names = new Set<string>();
|
||||
|
||||
public async listFilesRecursively(
|
||||
_root: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
return ["file.md"];
|
||||
}
|
||||
public async read(_path: RelativePath): Promise<Uint8Array> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async write(
|
||||
path: RelativePath,
|
||||
_content: Uint8Array
|
||||
): Promise<void> {
|
||||
this.names.add(path);
|
||||
}
|
||||
public async atomicUpdateText(
|
||||
_path: RelativePath,
|
||||
_updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
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 createDirectory(_path: RelativePath): Promise<void> {
|
||||
// this is called but irrelevant for this mock
|
||||
}
|
||||
public async delete(_path: RelativePath): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
public async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
this.names.delete(oldPath);
|
||||
this.names.add(newPath);
|
||||
}
|
||||
}
|
||||
|
||||
describe("File operations", () => {
|
||||
it("should deconflict renames", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
|
||||
await fileOperations.create("a", new Uint8Array());
|
||||
assertSetContainsExactly(fileSystemOperations.names, "a");
|
||||
await fileOperations.move("a", "b");
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b");
|
||||
|
||||
await fileOperations.create("c", new Uint8Array());
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b", "c");
|
||||
|
||||
await fileOperations.move("c", "b");
|
||||
assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)");
|
||||
|
||||
await fileOperations.create("c", new Uint8Array());
|
||||
await fileOperations.move("c", "b");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b",
|
||||
"b (1)",
|
||||
"b (2)"
|
||||
);
|
||||
});
|
||||
|
||||
it("should deconflict renames with file extension", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
|
||||
await fileOperations.create("b.md", new Uint8Array());
|
||||
await fileOperations.create("c.md", new Uint8Array());
|
||||
await fileOperations.move("c.md", "b.md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md"
|
||||
);
|
||||
|
||||
await fileOperations.create("d.md", new Uint8Array());
|
||||
await fileOperations.move("d.md", "b.md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md",
|
||||
"b (2).md"
|
||||
);
|
||||
|
||||
await fileOperations.create("file-23.md", new Uint8Array());
|
||||
await fileOperations.create("file-23 (1).md", new Uint8Array());
|
||||
await fileOperations.move("file-23.md", "file-23 (1).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"b.md",
|
||||
"b (1).md",
|
||||
"b (2).md",
|
||||
"file-23 (1).md",
|
||||
"file-23 (2).md"
|
||||
);
|
||||
});
|
||||
|
||||
it("should deconflict renames with paths", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
|
||||
await fileOperations.create("a/b.c/d", new Uint8Array());
|
||||
await fileOperations.create("a/b.c/e", new Uint8Array());
|
||||
await fileOperations.move("a/b.c/d", "a/b.c/e");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"a/b.c/e",
|
||||
"a/b.c/e (1)"
|
||||
);
|
||||
});
|
||||
|
||||
it("should continue deconfliction from existing number in filename", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
|
||||
await fileOperations.create("document (5).md", new Uint8Array());
|
||||
await fileOperations.create("other.md", new Uint8Array());
|
||||
|
||||
await fileOperations.move("other.md", "document (5).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"document (5).md",
|
||||
"document (6).md"
|
||||
);
|
||||
|
||||
await fileOperations.create("another.md", new Uint8Array());
|
||||
await fileOperations.move("another.md", "document (5).md");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
"document (5).md",
|
||||
"document (6).md",
|
||||
"document (7).md"
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle dotfiles correctly", async () => {
|
||||
const fileSystemOperations = new FakeFileSystemOperations();
|
||||
const fileOperations = new FileOperations(
|
||||
new Logger(),
|
||||
new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
fileSystemOperations,
|
||||
new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
);
|
||||
|
||||
await fileOperations.create(".gitignore", new Uint8Array());
|
||||
await fileOperations.create("temp", new Uint8Array());
|
||||
await fileOperations.move("temp", ".gitignore");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
".gitignore",
|
||||
".gitignore (1)"
|
||||
);
|
||||
|
||||
await fileOperations.create(".config.json", new Uint8Array());
|
||||
await fileOperations.create("temp2", new Uint8Array());
|
||||
await fileOperations.move("temp2", ".config.json");
|
||||
assertSetContainsExactly(
|
||||
fileSystemOperations.names,
|
||||
".gitignore",
|
||||
".gitignore (1)",
|
||||
".config.json",
|
||||
".config (1).json"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
import type { RelativePath } from "../persistence/database";
|
||||
import type { FileSystemOperations } from "./filesystem-operations";
|
||||
import type { Logger } from "../tracing/logger";
|
||||
import { Locks } from "../utils/data-structures/locks";
|
||||
import { FileNotFoundError } from "./file-not-found-error";
|
||||
import type { TextWithCursors } from "reconcile-text";
|
||||
|
||||
/**
|
||||
* Decorates `FileSystemOperations` to replace errors with `FileNotFoundError`
|
||||
* if the accessed file doesn't exist. It also ensures that there's at most a
|
||||
* single request in-flight for any one file through the use of locks.
|
||||
*/
|
||||
export class SafeFileSystemOperations implements FileSystemOperations {
|
||||
private readonly locks: Locks<RelativePath>;
|
||||
|
||||
public constructor(
|
||||
private readonly fs: FileSystemOperations,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.locks = new Locks(logger);
|
||||
}
|
||||
|
||||
public async listFilesRecursively(
|
||||
root: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
this.logger.debug("Listing all files");
|
||||
const result = await this.fs.listFilesRecursively(root);
|
||||
this.logger.debug(`Listed ${result.length} files`);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async read(path: RelativePath): Promise<Uint8Array> {
|
||||
this.logger.debug(`Reading file '${path}'`);
|
||||
return this.safeOperation(
|
||||
path,
|
||||
async () =>
|
||||
this.locks.withLock(path, async () => this.fs.read(path)),
|
||||
"read"
|
||||
);
|
||||
}
|
||||
|
||||
public async write(path: RelativePath, content: Uint8Array): Promise<void> {
|
||||
this.logger.debug(`Writing to file '${path}'`);
|
||||
return this.locks.withLock(path, async () =>
|
||||
this.fs.write(path, content)
|
||||
);
|
||||
}
|
||||
|
||||
public async atomicUpdateText(
|
||||
path: RelativePath,
|
||||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
this.logger.debug(`Atomically updating file '${path}'`);
|
||||
return this.safeOperation(
|
||||
path,
|
||||
async () =>
|
||||
this.locks.withLock(path, async () =>
|
||||
this.fs.atomicUpdateText(path, updater)
|
||||
),
|
||||
"atomicUpdateText"
|
||||
);
|
||||
}
|
||||
|
||||
public async getFileSize(path: RelativePath): Promise<number> {
|
||||
// Logging this would be too noisy
|
||||
return this.safeOperation(
|
||||
path,
|
||||
async () =>
|
||||
this.locks.withLock(path, async () =>
|
||||
this.fs.getFileSize(path)
|
||||
),
|
||||
"getFileSize"
|
||||
);
|
||||
}
|
||||
|
||||
public async exists(
|
||||
path: RelativePath,
|
||||
skipLock = false
|
||||
): Promise<boolean> {
|
||||
this.logger.debug(`Checking if file '${path}' exists`);
|
||||
if (skipLock) {
|
||||
return this.fs.exists(path);
|
||||
} else {
|
||||
return this.locks.withLock(path, async () => this.fs.exists(path));
|
||||
}
|
||||
}
|
||||
|
||||
public async createDirectory(path: RelativePath): Promise<void> {
|
||||
this.logger.debug(`Creating directory '${path}'`);
|
||||
return this.locks.withLock(path, async () =>
|
||||
this.fs.createDirectory(path)
|
||||
);
|
||||
}
|
||||
|
||||
public async delete(path: RelativePath): Promise<void> {
|
||||
this.logger.debug(`Deleting file '${path}'`);
|
||||
return this.locks.withLock(path, async () => this.fs.delete(path));
|
||||
}
|
||||
|
||||
public async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath,
|
||||
skipLock = false
|
||||
): Promise<void> {
|
||||
this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`);
|
||||
return this.safeOperation(
|
||||
oldPath,
|
||||
async () => {
|
||||
if (skipLock) {
|
||||
return this.fs.rename(oldPath, newPath);
|
||||
} else {
|
||||
return this.locks.withLock([oldPath, newPath], async () =>
|
||||
this.fs.rename(oldPath, newPath)
|
||||
);
|
||||
}
|
||||
},
|
||||
"rename"
|
||||
);
|
||||
}
|
||||
|
||||
public tryLock(path: RelativePath): boolean {
|
||||
return this.locks.tryLock(path);
|
||||
}
|
||||
|
||||
public async waitForLock(path: RelativePath): Promise<void> {
|
||||
return this.locks.waitForLock(path);
|
||||
}
|
||||
|
||||
public unlock(path: RelativePath): void {
|
||||
this.locks.unlock(path);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.locks.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorate an operation to ensure that the file exists before running it.
|
||||
* If the operation fails, it will check if the file still exists and throw
|
||||
* a FileNotFoundError if it doesn't.
|
||||
*/
|
||||
private async safeOperation<T>(
|
||||
path: RelativePath,
|
||||
operation: () => Promise<T>,
|
||||
operationName: string
|
||||
): Promise<T> {
|
||||
if (!(await this.fs.exists(path))) {
|
||||
throw new FileNotFoundError(
|
||||
`File not found before trying to ${operationName}`,
|
||||
path
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
// Without locking the file, this isn't atomic, however, it's good enough in practice.
|
||||
// This will only break if the file exists, gets deleted and then immediately
|
||||
// recreated while `operation` is running.
|
||||
if (await this.fs.exists(path)) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new FileNotFoundError(
|
||||
`File not found when trying to ${operationName}`,
|
||||
path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -134,9 +134,21 @@ describe("WebSocketManager", () => {
|
|||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
|
||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
||||
mockWs.simulateMessage({
|
||||
type: "vaultEvents",
|
||||
headEventId: 0,
|
||||
events: []
|
||||
});
|
||||
mockWs.simulateMessage({
|
||||
type: "vaultEvents",
|
||||
headEventId: 0,
|
||||
events: []
|
||||
});
|
||||
mockWs.simulateMessage({
|
||||
type: "vaultEvents",
|
||||
headEventId: 0,
|
||||
events: []
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
|
|
@ -218,6 +230,31 @@ describe("WebSocketManager", () => {
|
|||
assert.ok(true);
|
||||
});
|
||||
|
||||
it("uses a secure URL, preserves the configured prefix, and encodes the vault", async () => {
|
||||
mockSettings = {
|
||||
getSettings: () => ({
|
||||
remoteUri: "https://example.com/sync/",
|
||||
vaultName: "team/a ?",
|
||||
webSocketRetryIntervalMs: 1000
|
||||
})
|
||||
} as unknown as Settings;
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
mockLogger,
|
||||
mockSettings,
|
||||
MockWebSocket as unknown as typeof WebSocket
|
||||
);
|
||||
|
||||
manager.start();
|
||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
assert.strictEqual(
|
||||
String(mockWs.url),
|
||||
"wss://example.com/sync/vaults/team%2Fa%20%3F/ws"
|
||||
);
|
||||
await manager.stop();
|
||||
});
|
||||
|
||||
it("clears old handlers on reconnection", async () => {
|
||||
const manager = new WebSocketManager(
|
||||
deviceId,
|
||||
|
|
@ -278,7 +315,11 @@ describe("WebSocketManager", () => {
|
|||
|
||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||
.webSocket;
|
||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
||||
mockWs.simulateMessage({
|
||||
type: "vaultEvents",
|
||||
headEventId: 0,
|
||||
events: []
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
|
|
|
|||
9
frontend/sync-client/src/utils/package-version.test.ts
Normal file
9
frontend/sync-client/src/utils/package-version.test.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { it } from "node:test";
|
||||
import { packageVersion } from "./package-version";
|
||||
|
||||
it("supports direct source imports without webpack globals", async () => {
|
||||
assert.strictEqual(packageVersion, "development");
|
||||
const { SyncClient } = await import("../sync-client");
|
||||
assert.strictEqual(typeof SyncClient.create, "function");
|
||||
});
|
||||
Loading…
Reference in a new issue