simplify 3
This commit is contained in:
parent
82929d7479
commit
96d720eed9
8 changed files with 1318 additions and 995 deletions
|
|
@ -205,10 +205,21 @@ docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .
|
|||
|
||||
1. Creates `.vaultlink` directory for sync metadata
|
||||
2. Performs initial sync of local files to server
|
||||
3. Watches filesystem for changes using Node's `fs.watch`
|
||||
3. Uses filesystem events to wake a fresh scan, including atomic editor saves
|
||||
4. Syncs changes bidirectionally in real-time
|
||||
5. Handles graceful shutdown on SIGINT/SIGTERM
|
||||
|
||||
The complete client record (database, settings, queued logical notifications and
|
||||
server history checkpoint) is saved atomically in `.vaultlink/sync-data.json`.
|
||||
Existing database-only files migrate on the next save. Invalid or unreadable
|
||||
metadata is reported instead of silently starting with empty state.
|
||||
|
||||
File moves use an exclusive copy followed by deletion of the source. An
|
||||
interruption may leave both files; the next scan reconciles the visible files.
|
||||
Raw watcher events do not distinguish user edits from sync writes. A rename
|
||||
preserves identity when the scan finds a unique matching nonempty content hash;
|
||||
a simultaneous rename and edit may instead appear as deletion and creation.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import {
|
|||
DEFAULT_SETTINGS,
|
||||
LogLevel,
|
||||
LogLine,
|
||||
type SyncSettings,
|
||||
type StoredDatabase
|
||||
type SyncSettings
|
||||
} from "sync-client";
|
||||
import { parseArgs } from "./args";
|
||||
import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem";
|
||||
import { FileWatcher } from "./file-watcher";
|
||||
import { ClientPersistence } from "./client-persistence";
|
||||
import { formatLogLine } from "./logger-formatter";
|
||||
import packageJson from "../package.json";
|
||||
|
||||
|
|
@ -114,32 +114,7 @@ async function main(): Promise<void> {
|
|||
|
||||
const client = await SyncClient.create({
|
||||
fs: fileSystem,
|
||||
persistence: {
|
||||
load: async () => {
|
||||
let database: Partial<StoredDatabase> | undefined = undefined;
|
||||
try {
|
||||
const content = await fs.readFile(dataFile, "utf-8");
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
database = JSON.parse(content) as Partial<StoredDatabase>;
|
||||
} catch {
|
||||
emitBoot(
|
||||
LogLevel.WARNING,
|
||||
`Cannot read data file at ${dataFile}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
database
|
||||
};
|
||||
},
|
||||
save: async ({ database: persistedDatabase }) => {
|
||||
await fs.writeFile(
|
||||
dataFile,
|
||||
JSON.stringify(persistedDatabase, null, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
persistence: new ClientPersistence(dataFile, settings)
|
||||
});
|
||||
|
||||
if (args.health !== undefined) {
|
||||
|
|
@ -212,7 +187,6 @@ async function main(): Promise<void> {
|
|||
client.logger.info(`${signal} received, shutting down gracefully`);
|
||||
|
||||
fileWatcher.stop();
|
||||
await client.waitUntilFinished();
|
||||
await client.destroy();
|
||||
|
||||
if (totalSyncOps > 0) {
|
||||
|
|
|
|||
80
frontend/local-client-cli/src/client-persistence.test.ts
Normal file
80
frontend/local-client-cli/src/client-persistence.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { ClientPersistence } from "./client-persistence";
|
||||
import type { StoredClient } from "sync-client";
|
||||
|
||||
test("CLI persistence retains the whole record and migrates database-only saves", async (t) => {
|
||||
const directory = await fs.mkdtemp(
|
||||
path.join(tmpdir(), "vaultlink-metadata-")
|
||||
);
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const file = path.join(directory, "state.json");
|
||||
const store = new ClientPersistence(file, { token: "current-token" });
|
||||
assert.deepEqual(await store.load(), {
|
||||
settings: { token: "current-token" }
|
||||
});
|
||||
const record: StoredClient = {
|
||||
settings: { token: "old-token", vaultName: "vault" },
|
||||
database: { lastSeenUpdateId: 12 },
|
||||
localChanges: [
|
||||
{ type: "delete", path: "removed.md", changeId: "queued" }
|
||||
],
|
||||
localChangesVaultKey: "vault",
|
||||
historyCheckpoint: { vaultKey: "vault", checkpoint: "12:incarnation" }
|
||||
};
|
||||
await store.save(record);
|
||||
assert.deepEqual(await store.load(), {
|
||||
...record,
|
||||
settings: { ...record.settings, token: "current-token" }
|
||||
});
|
||||
const legacy = {
|
||||
fileManifest: { fileManifestId: 0, entries: {} },
|
||||
lastSeenUpdateId: 0
|
||||
};
|
||||
await fs.writeFile(file, JSON.stringify(legacy));
|
||||
assert.deepEqual((await store.load()).database, legacy);
|
||||
await fs.writeFile(file, "{truncated");
|
||||
await assert.rejects(store.load(), SyntaxError);
|
||||
await fs.rm(file);
|
||||
await fs.mkdir(file);
|
||||
await assert.rejects(store.load(), /EISDIR/);
|
||||
});
|
||||
|
||||
for (const afterReplacement of [false, true]) {
|
||||
test(`failed metadata save ${afterReplacement ? "after" : "before"} replacement leaves a complete record`, async (t) => {
|
||||
const directory = await fs.mkdtemp(
|
||||
path.join(tmpdir(), "vaultlink-save-")
|
||||
);
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const store = new ClientPersistence(
|
||||
path.join(directory, "state.json"),
|
||||
{}
|
||||
);
|
||||
const old: StoredClient = {
|
||||
historyCheckpoint: { vaultKey: "v", checkpoint: "1:old" }
|
||||
};
|
||||
const next: StoredClient = {
|
||||
...old,
|
||||
localChanges: [{ type: "delete", path: "note.md", changeId: "new" }]
|
||||
};
|
||||
await store.save(old);
|
||||
const rename = fs.rename;
|
||||
t.mock.method(
|
||||
fs,
|
||||
"rename",
|
||||
async (...args: Parameters<typeof fs.rename>) => {
|
||||
if (afterReplacement) await rename(...args);
|
||||
throw new Error("interrupted replacement");
|
||||
}
|
||||
);
|
||||
await assert.rejects(store.save(next), /interrupted replacement/);
|
||||
assert.deepEqual(await store.load(), {
|
||||
...(afterReplacement ? next : old),
|
||||
settings: {}
|
||||
});
|
||||
assert.deepEqual(await fs.readdir(directory), ["state.json"]);
|
||||
});
|
||||
}
|
||||
67
frontend/local-client-cli/src/client-persistence.ts
Normal file
67
frontend/local-client-cli/src/client-persistence.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
PersistenceProvider,
|
||||
StoredClient,
|
||||
StoredDatabase,
|
||||
SyncSettings
|
||||
} from "sync-client";
|
||||
|
||||
/** Only client metadata needs an atomic replacement; user files do not. */
|
||||
export class ClientPersistence implements PersistenceProvider<StoredClient> {
|
||||
public constructor(
|
||||
private readonly file: string,
|
||||
private readonly settings: Partial<SyncSettings>
|
||||
) {}
|
||||
|
||||
public async load(): Promise<StoredClient> {
|
||||
const content = await fs
|
||||
.readFile(this.file, "utf8")
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
)
|
||||
return undefined;
|
||||
throw error;
|
||||
});
|
||||
if (content === undefined) return { settings: this.settings };
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== "object" ||
|
||||
Array.isArray(parsed)
|
||||
)
|
||||
throw new Error("Invalid client metadata");
|
||||
// The sync engine validates and migrates the persisted fields.
|
||||
const data = parsed as StoredClient & Partial<StoredDatabase>;
|
||||
// Earlier CLI versions saved the database alone.
|
||||
const stored: StoredClient =
|
||||
"fileManifest" in data ? { database: data } : data;
|
||||
return {
|
||||
...stored,
|
||||
settings: { ...stored.settings, ...this.settings }
|
||||
};
|
||||
}
|
||||
|
||||
public async save(data: StoredClient): Promise<void> {
|
||||
await fs.mkdir(path.dirname(this.file), { recursive: true });
|
||||
const temporary = `${this.file}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
const handle = await fs.open(temporary, "wx", 0o600);
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify(data));
|
||||
// Flush the complete value before exposing its name. A crash can
|
||||
// keep the previous value, but must not expose truncated JSON.
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await fs.rename(temporary, this.file);
|
||||
} finally {
|
||||
await fs.rm(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +1,54 @@
|
|||
import Watcher from "watcher";
|
||||
import * as path from "path";
|
||||
import type { SyncClient, RelativePath } from "sync-client";
|
||||
import * as path from "node:path";
|
||||
import type { SyncClient } from "sync-client";
|
||||
import { toUnixPath, matchesGlob } from "./path-utils";
|
||||
|
||||
/** OS events include sync writes and editor temporary-file replacements. They
|
||||
* wake reconciliation; only a fresh scan determines the current namespace. */
|
||||
export class FileWatcher {
|
||||
private watcher: Watcher | undefined;
|
||||
private isRunning = false;
|
||||
private readonly ignorePatterns: string[];
|
||||
|
||||
public constructor(
|
||||
private readonly basePath: string,
|
||||
private readonly client: SyncClient,
|
||||
ignorePatterns: string[] = []
|
||||
) {
|
||||
this.ignorePatterns = ignorePatterns;
|
||||
}
|
||||
private readonly ignorePatterns: string[] = []
|
||||
) {}
|
||||
|
||||
public start(): void {
|
||||
if (this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
if (this.watcher) return;
|
||||
this.watcher = new Watcher(this.basePath, {
|
||||
recursive: true,
|
||||
renameDetection: true,
|
||||
renameTimeout: 125,
|
||||
renameDetection: false,
|
||||
ignoreInitial: true,
|
||||
ignore: (filePath: string): boolean => this.shouldIgnore(filePath)
|
||||
ignore: (filePath: string): boolean =>
|
||||
this.ignorePatterns.some((pattern) =>
|
||||
matchesGlob(this.toRelativePath(filePath), pattern)
|
||||
)
|
||||
});
|
||||
|
||||
this.watcher.on("add", (filePath: string) => {
|
||||
this.handleCreate(this.toRelativePath(filePath));
|
||||
this.watcher.on("all", (_event: string, filePath: string) => {
|
||||
void this.client
|
||||
.syncLocallyUpdatedFile({
|
||||
relativePath: this.toRelativePath(filePath)
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.client.logger.error(
|
||||
`File notification failed: ${error}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
this.watcher.on("change", (filePath: string) => {
|
||||
this.handleChange(this.toRelativePath(filePath));
|
||||
this.watcher.on("error", (error: Error) => {
|
||||
this.client.logger.error(`File watcher failed: ${error.message}`);
|
||||
});
|
||||
|
||||
this.watcher.on("unlink", (filePath: string) => {
|
||||
this.handleDelete(this.toRelativePath(filePath));
|
||||
});
|
||||
|
||||
this.watcher.on("rename", (oldPath: string, newPath: string) => {
|
||||
this.handleRename(
|
||||
this.toRelativePath(oldPath),
|
||||
this.toRelativePath(newPath)
|
||||
);
|
||||
});
|
||||
|
||||
this.client.logger.info("File watcher started");
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
if (this.watcher !== undefined) {
|
||||
this.watcher.close();
|
||||
this.watcher = undefined;
|
||||
}
|
||||
this.isRunning = false;
|
||||
this.watcher?.close();
|
||||
this.watcher = undefined;
|
||||
this.client.logger.info("File watcher stopped");
|
||||
}
|
||||
|
||||
private shouldIgnore(filePath: string): boolean {
|
||||
const rel = toUnixPath(path.relative(this.basePath, filePath));
|
||||
return this.ignorePatterns.some((pattern) => matchesGlob(rel, pattern));
|
||||
}
|
||||
|
||||
private handleCreate(relativePath: RelativePath): void {
|
||||
this.client.syncLocallyCreatedFile(relativePath);
|
||||
}
|
||||
|
||||
private handleChange(relativePath: RelativePath): void {
|
||||
this.client.syncLocallyUpdatedFile({ relativePath });
|
||||
}
|
||||
|
||||
private handleDelete(relativePath: RelativePath): void {
|
||||
this.client.syncLocallyDeletedFile(relativePath);
|
||||
}
|
||||
|
||||
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
|
||||
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
|
||||
this.client.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath: newPath
|
||||
});
|
||||
}
|
||||
|
||||
private toRelativePath(absolutePath: string): RelativePath {
|
||||
private toRelativePath(absolutePath: string): string {
|
||||
return toUnixPath(path.relative(this.basePath, absolutePath));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,13 @@ test("NodeFileSystemOperations - read and write files", async () => {
|
|||
|
||||
try {
|
||||
const content = new TextEncoder().encode("Hello, world!");
|
||||
await fsOps.write("test.txt", content);
|
||||
await fsOps.write("test.txt", { content });
|
||||
|
||||
const readContent = await fsOps.read("test.txt");
|
||||
assert.equal(new TextDecoder().decode(readContent), "Hello, world!");
|
||||
const readContent = await fsOps.readSnapshot("test.txt");
|
||||
assert.equal(
|
||||
new TextDecoder().decode(readContent!.content),
|
||||
"Hello, world!"
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -27,10 +30,13 @@ test("NodeFileSystemOperations - create nested directories with forward slashes"
|
|||
try {
|
||||
const content = new TextEncoder().encode("Nested file");
|
||||
// Always use forward slashes in API
|
||||
await fsOps.write("dir1/dir2/test.txt", content);
|
||||
await fsOps.write("dir1/dir2/test.txt", { content });
|
||||
|
||||
const readContent = await fsOps.read("dir1/dir2/test.txt");
|
||||
assert.equal(new TextDecoder().decode(readContent), "Nested file");
|
||||
const readContent = await fsOps.readSnapshot("dir1/dir2/test.txt");
|
||||
assert.equal(
|
||||
new TextDecoder().decode(readContent!.content),
|
||||
"Nested file"
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -43,7 +49,9 @@ test("NodeFileSystemOperations - exists with forward slashes", async () => {
|
|||
try {
|
||||
assert.equal(await fsOps.exists("test.txt"), false);
|
||||
|
||||
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
||||
await fsOps.write("test.txt", {
|
||||
content: new TextEncoder().encode("test")
|
||||
});
|
||||
|
||||
assert.equal(await fsOps.exists("test.txt"), true);
|
||||
} finally {
|
||||
|
|
@ -56,7 +64,9 @@ test("NodeFileSystemOperations - deleteFile with forward slashes", async () => {
|
|||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
||||
try {
|
||||
await fsOps.write("test.txt", new TextEncoder().encode("test"));
|
||||
await fsOps.write("test.txt", {
|
||||
content: new TextEncoder().encode("test")
|
||||
});
|
||||
assert.equal(await fsOps.exists("test.txt"), true);
|
||||
|
||||
await fsOps.deleteFile("test.txt");
|
||||
|
|
@ -74,15 +84,18 @@ test("NodeFileSystemOperations - rename with forward slashes", async () => {
|
|||
|
||||
try {
|
||||
const content = new TextEncoder().encode("test content");
|
||||
await fsOps.write("old.txt", content);
|
||||
await fsOps.write("old.txt", { content });
|
||||
|
||||
await fsOps.rename("old.txt", "new.txt");
|
||||
|
||||
assert.equal(await fsOps.exists("old.txt"), false);
|
||||
assert.equal(await fsOps.exists("new.txt"), true);
|
||||
|
||||
const readContent = await fsOps.read("new.txt");
|
||||
assert.equal(new TextDecoder().decode(readContent), "test content");
|
||||
const readContent = await fsOps.readSnapshot("new.txt");
|
||||
assert.equal(
|
||||
new TextDecoder().decode(readContent!.content),
|
||||
"test content"
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -94,7 +107,7 @@ test("NodeFileSystemOperations - rename to nested path with forward slashes", as
|
|||
|
||||
try {
|
||||
const content = new TextEncoder().encode("test content");
|
||||
await fsOps.write("old.txt", content);
|
||||
await fsOps.write("old.txt", { content });
|
||||
|
||||
await fsOps.rename("old.txt", "dir1/dir2/new.txt");
|
||||
|
||||
|
|
@ -105,42 +118,21 @@ test("NodeFileSystemOperations - rename to nested path with forward slashes", as
|
|||
}
|
||||
});
|
||||
|
||||
test("NodeFileSystemOperations - getFileSize", async () => {
|
||||
test("NodeFileSystemOperations - stat", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
||||
try {
|
||||
const content = new TextEncoder().encode("Hello!");
|
||||
await fsOps.write("test.txt", content);
|
||||
await fsOps.write("test.txt", { content });
|
||||
|
||||
const size = await fsOps.getFileSize("test.txt");
|
||||
const size = (await fsOps.stat("test.txt"))!.size;
|
||||
assert.equal(size, content.length);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("NodeFileSystemOperations - atomicUpdateText", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
||||
try {
|
||||
await fsOps.write("test.txt", new TextEncoder().encode("Hello"));
|
||||
|
||||
const result = await fsOps.atomicUpdateText("test.txt", (current) => ({
|
||||
text: current.text + " World",
|
||||
cursors: []
|
||||
}));
|
||||
|
||||
assert.equal(result, "Hello World");
|
||||
|
||||
const content = await fsOps.read("test.txt");
|
||||
assert.equal(new TextDecoder().decode(content), "Hello World");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("NodeFileSystemOperations - handles paths with forward slashes on all platforms", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "vaultlink-test-"));
|
||||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
|
@ -150,11 +142,11 @@ test("NodeFileSystemOperations - handles paths with forward slashes on all platf
|
|||
const testPath = "deep/nested/directory/file.txt";
|
||||
const content = new TextEncoder().encode("test");
|
||||
|
||||
await fsOps.write(testPath, content);
|
||||
await fsOps.write(testPath, { content });
|
||||
assert.equal(await fsOps.exists(testPath), true);
|
||||
|
||||
const readContent = await fsOps.read(testPath);
|
||||
assert.equal(new TextDecoder().decode(readContent), "test");
|
||||
const readContent = await fsOps.readSnapshot(testPath);
|
||||
assert.equal(new TextDecoder().decode(readContent!.content), "test");
|
||||
|
||||
await fsOps.deleteFile(testPath);
|
||||
assert.equal(await fsOps.exists(testPath), false);
|
||||
|
|
@ -186,10 +178,9 @@ test("NodeFileSystemOperations - delete does not remove files", async () => {
|
|||
const fsOps = new NodeFileSystemOperations(tempDir);
|
||||
|
||||
try {
|
||||
await fsOps.write(
|
||||
"parent/file.txt",
|
||||
new TextEncoder().encode("preserve me")
|
||||
);
|
||||
await fsOps.write("parent/file.txt", {
|
||||
content: new TextEncoder().encode("preserve me")
|
||||
});
|
||||
|
||||
await assert.rejects(async () => fsOps.delete("parent"));
|
||||
assert.equal(await fsOps.exists("parent/file.txt"), true);
|
||||
|
|
|
|||
|
|
@ -1,239 +1,188 @@
|
|||
import * as fs from "fs/promises";
|
||||
import type { Dirent } from "fs";
|
||||
import * as path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import type {
|
||||
FileSystemOperations,
|
||||
RelativePath,
|
||||
TextWithCursors
|
||||
} from "sync-client";
|
||||
import { toUnixPath } from "./path-utils";
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { constants, type Stats } from "node:fs";
|
||||
import type { FileSystemOperations, FileSnapshot } from "sync-client";
|
||||
|
||||
function hasCode(error: unknown, ...codes: string[]): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
"code" in error &&
|
||||
typeof error.code === "string" &&
|
||||
codes.includes(error.code)
|
||||
);
|
||||
}
|
||||
|
||||
// VaultLink's per-vault metadata directory. Holds the persisted sync database
|
||||
// and the tmp files atomicWrite renames into place; the matching `${VAULTLINK_DIR}/**`
|
||||
// ignore pattern keeps everything in here invisible to the file watcher.
|
||||
export const VAULTLINK_DIR = ".vaultlink";
|
||||
|
||||
/** Preflights reject unsafe paths and links. This is not a sandbox against a
|
||||
* hostile process concurrently replacing ancestor directories. */
|
||||
export class NodeFileSystemOperations implements FileSystemOperations {
|
||||
public constructor(private readonly basePath: string) {}
|
||||
|
||||
public async listFilesRecursively(
|
||||
directory: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
const files: RelativePath[] = [];
|
||||
await this.walkDirectory(directory ?? "", files);
|
||||
return files;
|
||||
public constructor(private readonly root: string) {}
|
||||
public async stat(
|
||||
relative: string
|
||||
): ReturnType<FileSystemOperations["stat"]> {
|
||||
const info = await this.info(await this.resolve(relative));
|
||||
return info
|
||||
? {
|
||||
kind: info.isFile()
|
||||
? ("file" as const)
|
||||
: ("directory" as const),
|
||||
size: info.size
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
try {
|
||||
return await fs.readFile(fullPath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
public async exists(relative: string): Promise<boolean> {
|
||||
return (await this.stat(relative)) !== undefined;
|
||||
}
|
||||
|
||||
public async write(
|
||||
relativePath: RelativePath,
|
||||
content: Uint8Array
|
||||
): Promise<void> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
const dir = path.dirname(fullPath);
|
||||
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await this.atomicWrite(fullPath, content);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async atomicUpdateText(
|
||||
relativePath: RelativePath,
|
||||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
|
||||
try {
|
||||
const currentContent = await fs.readFile(fullPath, "utf-8");
|
||||
const result = updater({ text: currentContent, cursors: [] });
|
||||
await this.atomicWrite(fullPath, result.text, "utf-8");
|
||||
return result.text;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to atomically update file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
try {
|
||||
const stats = await fs.stat(fullPath);
|
||||
return stats.size;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to get file size for ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async exists(relativePath: RelativePath): Promise<boolean> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
try {
|
||||
await fs.mkdir(fullPath, { recursive: false });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to create directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async delete(relativePath: RelativePath): Promise<void> {
|
||||
if (!relativePath) {
|
||||
throw new Error("Cannot delete the vault root");
|
||||
}
|
||||
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
try {
|
||||
await this.deleteDirectoryTree(fullPath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to delete directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteFile(relativePath: RelativePath): Promise<void> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
try {
|
||||
const entry = await fs.lstat(fullPath).catch((error: unknown) => {
|
||||
if (this.isMissing(error)) return undefined;
|
||||
public async readSnapshot(
|
||||
relative: string
|
||||
): Promise<FileSnapshot | undefined> {
|
||||
const absolute = await this.resolve(relative);
|
||||
const handle = await fs
|
||||
.open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW)
|
||||
.catch((error: unknown) => {
|
||||
if (hasCode(error, "ENOENT", "ENOTDIR")) return undefined;
|
||||
throw error;
|
||||
});
|
||||
if (!entry) return;
|
||||
if (!entry.isFile() || entry.nlink !== 1) {
|
||||
throw new Error("Cannot unlink a non-regular or linked file");
|
||||
}
|
||||
|
||||
await fs.unlink(fullPath).catch((error: unknown) => {
|
||||
if (!this.isMissing(error)) throw error;
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to delete file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async rename(
|
||||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
const oldFullPath = path.join(this.basePath, oldPath);
|
||||
const newFullPath = path.join(this.basePath, newPath);
|
||||
const newDir = path.dirname(newFullPath);
|
||||
|
||||
if (!handle) return undefined;
|
||||
try {
|
||||
await fs.mkdir(newDir, { recursive: true });
|
||||
await fs.rename(oldFullPath, newFullPath);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to rename file from ${oldFullPath} to ${newFullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
const before = await handle.stat({ bigint: true });
|
||||
assert(
|
||||
before.isFile() && before.nlink === 1n,
|
||||
"Not an independent regular file"
|
||||
);
|
||||
const content = new Uint8Array(await handle.readFile());
|
||||
const after = await handle.stat({ bigint: true });
|
||||
const atPath = await fs.lstat(absolute, { bigint: true });
|
||||
assert(
|
||||
before.ino === after.ino &&
|
||||
after.ino === atPath.ino &&
|
||||
before.size === after.size &&
|
||||
before.mtimeNs === after.mtimeNs &&
|
||||
before.ctimeNs === after.ctimeNs,
|
||||
"File changed during read"
|
||||
);
|
||||
return { content };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async atomicWrite(
|
||||
fullPath: string,
|
||||
content: Uint8Array | string,
|
||||
encoding?: BufferEncoding
|
||||
): Promise<void> {
|
||||
const tmpDir = path.join(this.basePath, VAULTLINK_DIR);
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
const tmpPath = path.join(tmpDir, `atomic-write-${randomUUID()}.tmp`);
|
||||
try {
|
||||
await fs.writeFile(tmpPath, content, encoding);
|
||||
await fs.rename(tmpPath, fullPath);
|
||||
} catch (error) {
|
||||
await fs.unlink(tmpPath).catch(() => undefined);
|
||||
throw error;
|
||||
public async listFilesRecursively(root = ""): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
for (const name of await fs.readdir(await this.resolve(root))) {
|
||||
const relative = root ? `${root}/${name}` : name;
|
||||
const entry = await this.stat(relative);
|
||||
assert(entry, `File changed during scan: ${relative}`);
|
||||
if (entry.kind === "directory")
|
||||
result.push(...(await this.listFilesRecursively(relative)));
|
||||
else result.push(relative);
|
||||
}
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
private async deleteDirectoryTree(fullPath: string): Promise<void> {
|
||||
const entry = await fs.lstat(fullPath).catch((error: unknown) => {
|
||||
if (this.isMissing(error)) return undefined;
|
||||
throw error;
|
||||
});
|
||||
if (!entry) return;
|
||||
if (!entry.isDirectory()) {
|
||||
throw new Error("Cannot delete a regular file as a directory");
|
||||
}
|
||||
|
||||
for (const child of await fs.readdir(fullPath, {
|
||||
withFileTypes: true
|
||||
})) {
|
||||
if (!child.isDirectory()) {
|
||||
throw new Error(
|
||||
`Directory contains a non-directory entry: ${path.join(fullPath, child.name)}`
|
||||
);
|
||||
public async createDirectory(relative: string): Promise<void> {
|
||||
await this.resolve(relative);
|
||||
let current = this.root;
|
||||
for (const component of relative.split("/").filter(Boolean)) {
|
||||
const next = path.join(current, component);
|
||||
try {
|
||||
await fs.mkdir(next);
|
||||
} catch (error) {
|
||||
if (!hasCode(error, "EEXIST")) throw error;
|
||||
}
|
||||
await this.deleteDirectoryTree(path.join(fullPath, child.name));
|
||||
assert((await fs.lstat(next)).isDirectory());
|
||||
current = next;
|
||||
}
|
||||
|
||||
await fs.rmdir(fullPath);
|
||||
}
|
||||
|
||||
private isMissing(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === "ENOENT"
|
||||
public async write(
|
||||
relative: string,
|
||||
snapshot: FileSnapshot
|
||||
): Promise<void> {
|
||||
const destination = await this.resolve(relative);
|
||||
await this.createDirectory(
|
||||
path.posix.dirname(relative) === "."
|
||||
? ""
|
||||
: path.posix.dirname(relative)
|
||||
);
|
||||
await fs.writeFile(destination, snapshot.content, {
|
||||
flag: "wx",
|
||||
mode: 0o600
|
||||
});
|
||||
}
|
||||
|
||||
private async walkDirectory(
|
||||
relativePath: string,
|
||||
files: RelativePath[]
|
||||
): Promise<void> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
let entries: Dirent[] = [];
|
||||
|
||||
try {
|
||||
entries = await fs.readdir(fullPath, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read directory ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
public async rename(from: string, to: string): Promise<void> {
|
||||
const snapshot = await this.readSnapshot(from);
|
||||
assert(snapshot, `Missing source: ${from}`);
|
||||
// Node's rename overwrites destinations on POSIX. Exclusive copy followed
|
||||
// by unlink leaves ordinary discoverable files if interrupted.
|
||||
await this.write(to, snapshot);
|
||||
const current = await this.readSnapshot(from);
|
||||
assert(
|
||||
current &&
|
||||
Buffer.from(current.content).equals(
|
||||
Buffer.from(snapshot.content)
|
||||
),
|
||||
`Source changed during move: ${from}`
|
||||
);
|
||||
await this.deleteFile(from);
|
||||
}
|
||||
public async delete(relative: string): Promise<void> {
|
||||
assert(relative, "Cannot prune root");
|
||||
const absolute = await this.resolve(relative);
|
||||
const entry = await this.info(absolute);
|
||||
if (!entry) return;
|
||||
assert(entry.isDirectory(), "Cannot delete regular file");
|
||||
for (const child of await fs.readdir(absolute))
|
||||
await this.delete(`${relative}/${child}`);
|
||||
await fs.rmdir(absolute);
|
||||
}
|
||||
public async deleteFile(relative: string): Promise<void> {
|
||||
const absolute = await this.resolve(relative);
|
||||
const entry = await this.info(absolute);
|
||||
if (!entry) return;
|
||||
assert(entry.isFile() && entry.nlink === 1, "Cannot unlink non-file");
|
||||
await fs.unlink(absolute).catch((error: unknown) => {
|
||||
if (!hasCode(error, "ENOENT")) throw error;
|
||||
});
|
||||
}
|
||||
private async resolve(relative: string): Promise<string> {
|
||||
assert(
|
||||
relative === "" ||
|
||||
relative
|
||||
.split("/")
|
||||
.every(
|
||||
(p) =>
|
||||
p !== "" &&
|
||||
p !== "." &&
|
||||
p !== ".." &&
|
||||
!p.includes("\\") &&
|
||||
!p.includes("\0") &&
|
||||
(process.platform !== "win32" || !p.includes(":"))
|
||||
),
|
||||
"Unsafe path"
|
||||
);
|
||||
assert(
|
||||
(await fs.lstat(this.root)).isDirectory(),
|
||||
"Vault root must be a directory, not a symlink"
|
||||
);
|
||||
let current = this.root;
|
||||
for (const component of relative.split("/").filter(Boolean)) {
|
||||
current = path.join(current, component);
|
||||
const entry = await this.info(current);
|
||||
if (entry)
|
||||
assert(
|
||||
entry.isDirectory() ||
|
||||
(entry.isFile() && entry.nlink === 1),
|
||||
"Symlink, hardlink or special file"
|
||||
);
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryName = entry.name;
|
||||
const entryRelativePath = path.join(relativePath, entryName);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
await this.walkDirectory(entryRelativePath, files);
|
||||
} else if (entry.isFile()) {
|
||||
// Always return forward slashes
|
||||
files.push(toUnixPath(entryRelativePath));
|
||||
}
|
||||
return current;
|
||||
}
|
||||
private async info(absolute: string): Promise<Stats | undefined> {
|
||||
try {
|
||||
return await fs.lstat(absolute);
|
||||
} catch (error) {
|
||||
if (hasCode(error, "ENOENT", "ENOTDIR")) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ module.exports = {
|
|||
healthcheck: "./src/healthcheck.ts"
|
||||
},
|
||||
target: "node",
|
||||
externals: { bufferutil: "bufferutil", "utf-8-validate": "utf-8-validate" },
|
||||
mode: "production",
|
||||
optimization: {
|
||||
minimize: false
|
||||
|
|
|
|||
Loading…
Reference in a new issue