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 })
|
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||||
.webSocket;
|
.webSocket;
|
||||||
|
|
||||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
mockWs.simulateMessage({
|
||||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
type: "vaultEvents",
|
||||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
headEventId: 0,
|
||||||
|
events: []
|
||||||
|
});
|
||||||
|
mockWs.simulateMessage({
|
||||||
|
type: "vaultEvents",
|
||||||
|
headEventId: 0,
|
||||||
|
events: []
|
||||||
|
});
|
||||||
|
mockWs.simulateMessage({
|
||||||
|
type: "vaultEvents",
|
||||||
|
headEventId: 0,
|
||||||
|
events: []
|
||||||
|
});
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
|
@ -218,6 +230,31 @@ describe("WebSocketManager", () => {
|
||||||
assert.ok(true);
|
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 () => {
|
it("clears old handlers on reconnection", async () => {
|
||||||
const manager = new WebSocketManager(
|
const manager = new WebSocketManager(
|
||||||
deviceId,
|
deviceId,
|
||||||
|
|
@ -278,7 +315,11 @@ describe("WebSocketManager", () => {
|
||||||
|
|
||||||
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
const mockWs = (manager as unknown as { webSocket: MockWebSocket })
|
||||||
.webSocket;
|
.webSocket;
|
||||||
mockWs.simulateMessage({ type: "vaultUpdate", updates: [] });
|
mockWs.simulateMessage({
|
||||||
|
type: "vaultEvents",
|
||||||
|
headEventId: 0,
|
||||||
|
events: []
|
||||||
|
});
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
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