Adopt Commonlib storage path contracts

Use the shared Node adapter and retain one comparison policy throughout
each CLI scan. Propagate permission and I/O failures instead of reporting
absence or a successful partial scan.

Use shared namespace and comparison helpers in Obsidian while preserving
its Vault APIs and alias policy. Cover native spelling and host filename
compatibility, and document the dependency and integration boundaries.
This commit is contained in:
vorotamoroz committed 2026-11-03 08:44:31 +00:00
1 parent 2716d68b3d
commit d557298ceb
13 files changed
+301 -272

No files matched your search

+92 -24
View File
@@ -9,6 +9,12 @@ import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
import { path } from "@vrtmrz/livesync-commonlib/node";
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
import {
createStoragePathResolver,
parseFilePath,
type StoragePath,
type StoragePathResolver,
} from "@vrtmrz/livesync-commonlib/storage";
/**
* Complete file system adapter implementation for Node.js
@@ -21,6 +27,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
readonly vault: NodeVaultAdapter;
private fileCache = new Map<string, NodeFile>();
private readonly isCaseInsensitiveSetting: () => boolean;
constructor(
private basePath: string,
@@ -31,6 +38,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
this.path = new NodePathAdapter();
this.typeGuard = new NodeTypeGuardAdapter();
this.conversion = new NodeConversionAdapter();
this.isCaseInsensitiveSetting = isCaseInsensitive;
this.storage = new UnicodeResolvingNodeStorageAdapter(basePath, getPathNormalizationVersion, isCaseInsensitive);
this.vault = new NodeVaultAdapter(this.storage);
}
@@ -39,8 +47,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
return path.join(this.basePath, p);
}
private normalisePath(p: FilePath | string): string {
return this.path.normalisePath(p);
private normalisePath(p: FilePath | string): FilePath {
return parseFilePath(this.path.normalisePath(p));
}
private async hasExactPathCase(pathStr: string): Promise<boolean> {
@@ -122,15 +130,28 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
async refreshFile(p: string, caseInsensitive = false): Promise<NodeFile | null> {
const pathStr = this.normalisePath(p);
if (this.storage.isUnicodePathNormalizationEnabled) {
try {
const resolvedPath = await this.storage.resolveLogicalPath(pathStr, caseInsensitive, false);
if (resolvedPath === null) {
this.fileCache.delete(pathStr);
return null;
}
return await this.refreshResolvedFile(resolvedPath);
} catch (error) {
this.fileCache.delete(pathStr);
throw error;
}
}
try {
const resolvedPath = this.storage.isUnicodePathNormalizationEnabled
? await this.storage.resolveLogicalPath(pathStr, caseInsensitive, false)
: pathStr;
if (resolvedPath === null) {
const stat = await this.storage.stat(pathStr, caseInsensitive);
if (stat?.type !== "file") {
this.fileCache.delete(pathStr);
return null;
}
return await this.refreshResolvedFile(resolvedPath);
const file: NodeFile = { path: pathStr, stat };
this.fileCache.set(pathStr, file);
return file;
} catch (error) {
if (error instanceof AmbiguousNormalisedPathError) throw error;
this.fileCache.delete(pathStr);
@@ -138,16 +159,16 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
}
}
private async refreshResolvedFile(resolvedPath: string): Promise<NodeFile | null> {
private async refreshResolvedFile(resolvedPath: StoragePath): Promise<NodeFile | null> {
try {
const stat = await this.storage.statExact(resolvedPath);
const stat = await this.storage.statResolved(resolvedPath);
if (stat?.type !== "file") {
this.fileCache.delete(resolvedPath);
return null;
}
const file: NodeFile = {
path: resolvedPath as FilePath,
path: resolvedPath,
stat: {
size: stat.size,
mtime: stat.mtime,
@@ -158,10 +179,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
this.fileCache.set(resolvedPath, file);
return file;
} catch (error) {
if (error instanceof AmbiguousNormalisedPathError) throw error;
// Evict so a deleted file is not returned by subsequent cache scans.
this.fileCache.delete(resolvedPath);
return null;
throw error;
}
}
@@ -169,34 +188,83 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
* Helper method to recursively scan directory and populate file cache
*/
async scanDirectory(relativePath: string = ""): Promise<void> {
const resolvedPath = await this.storage.resolveLogicalPath(relativePath, false, false);
if (resolvedPath !== null) await this.scanDirectoryInto(resolvedPath, this.fileCache);
const files = new Map<string, NodeFile>();
await this.scanDirectoryInto(relativePath, files);
for (const [path, file] of files) this.fileCache.set(path, file);
}
private async scanDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
const resolver = this.createScanResolver();
const filePath = parseFilePath(relativePath);
if (resolver) {
const resolvedPath =
filePath === "" ? await resolver.resolveForWrite(filePath) : await resolver.resolveExisting(filePath);
if (resolvedPath !== null) await this.scanResolvedDirectoryInto(resolver, resolvedPath, files);
return;
}
await this.scanLegacyDirectoryInto(filePath, files);
}
private createScanResolver(): StoragePathResolver | null {
if (!this.storage.isUnicodePathNormalizationEnabled) return null;
const caseInsensitive = this.isCaseInsensitiveSetting();
return createStoragePathResolver({
readDirectory: async (physicalDirectory) => await this.storage.readDirectory(physicalDirectory),
policy: {
pathNormalizationVersion: 1,
handleFilenameCaseSensitive: !caseInsensitive,
},
});
}
private async scanResolvedDirectoryInto(
resolver: StoragePathResolver,
relativePath: StoragePath,
files: Map<string, NodeFile>
): Promise<void> {
const directoryStat = await this.storage.statResolved(relativePath);
if (directoryStat === null) return;
if (directoryStat.type !== "folder") {
throw new Error(`Path is not a directory: ${this.resolvePath(relativePath)}`);
}
const entries = await resolver.listResolvedDirectory(relativePath);
if (entries === null) return;
for (const entry of entries) {
if (entry.kind === "directory") {
await this.scanResolvedDirectoryInto(resolver, entry.path, files);
continue;
}
const stat = await this.storage.statResolved(entry.path);
if (stat?.type !== "file") continue;
const file: NodeFile = { path: entry.path, stat };
files.set(entry.path, file);
}
}
private async scanLegacyDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
const fullPath = this.resolvePath(relativePath);
try {
const directoryStat = this.storage.isUnicodePathNormalizationEnabled
? await this.storage.statExact(relativePath)
: await this.storage.stat(relativePath, false);
const directoryStat = await this.storage.stat(relativePath, false);
if (directoryStat?.type !== "folder") throw new Error(`Directory does not exist: ${fullPath}`);
const entries = await this.storage.listExact(relativePath);
const entries = await this.storage.list(relativePath, false);
for (const entryPath of entries.files) {
const stat = await this.storage.statExact(entryPath);
const filePath = parseFilePath(entryPath);
const stat = await this.storage.stat(filePath, false);
if (stat?.type !== "file") continue;
const file: NodeFile = {
path: entryPath as FilePath,
path: filePath,
stat,
};
files.set(entryPath, file);
files.set(filePath, file);
}
for (const entryPath of entries.folders) {
await this.scanDirectoryInto(entryPath, files);
await this.scanLegacyDirectoryInto(entryPath, files);
}
} catch (error) {
if (error instanceof AmbiguousNormalisedPathError) throw error;
// Directory doesn't exist or is not readable
this.reportDiagnostic(`Error scanning directory ${fullPath}:`, error);
}
}
@@ -1,12 +1,14 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fsPromises as fs, NodeStorageAdapter, os, path } from "@vrtmrz/livesync-commonlib/node";
import { DEFAULT_SETTINGS, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { parseFilePath, type StoragePath } from "@vrtmrz/livesync-commonlib/storage";
import {
ServiceRebuilder,
type ServiceRebuilderDependencies,
} from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import { AmbiguousNormalisedPathError } from "./UnicodeResolvingNodeStorageAdapter";
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
describe("NodeFileSystemAdapter file enumeration", () => {
@@ -219,6 +221,77 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
);
}
it("propagates path-resolution failures when refreshing a file", async () => {
const directory = await createVault();
const adapter = createNormalisingAdapter(directory);
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
vi.spyOn(adapter.storage, "resolveLogicalPath").mockRejectedValue(error);
await expect(adapter.refreshFile("a.md")).rejects.toBe(error);
});
it("propagates strict metadata failures when refreshing a resolved file", async () => {
const directory = await createVault();
await fs.writeFile(path.join(directory, "a.md"), "content");
const adapter = createNormalisingAdapter(directory);
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
vi.spyOn(adapter.storage, "statResolved").mockRejectedValue(error);
await expect(adapter.getAbstractFileByPath("a.md")).rejects.toBe(error);
});
it("keeps resolved metadata reads strict when the identity mode changes during lookup", async () => {
const directory = await createVault();
await fs.writeFile(path.join(directory, "a.md"), "content");
let version = 1;
const adapter = new NodeFileSystemAdapter(directory, undefined, () => version);
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
const resolve = adapter.storage.resolveLogicalPath.bind(adapter.storage);
vi.spyOn(adapter.storage, "resolveLogicalPath").mockImplementation(async (...args) => {
const result = await resolve(...args);
version = 0;
return result;
});
vi.spyOn(adapter.storage, "statResolved").mockRejectedValue(error);
await expect(adapter.getAbstractFileByPath("a.md")).rejects.toBe(error);
});
it("propagates strict directory-read failures without reporting an incomplete scan", async () => {
const directory = await createVault();
const report = vi.fn();
const adapter = new NodeFileSystemAdapter(directory, report, () => 1);
const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" });
vi.spyOn(adapter.storage, "readDirectory").mockRejectedValue(error);
await expect(adapter.getFiles()).rejects.toBe(error);
expect(report).not.toHaveBeenCalled();
});
it("uses one comparison policy snapshot for the whole NFC scan", async () => {
const directory = await createVault();
await fs.mkdir(path.join(directory, "Folder"));
await fs.writeFile(path.join(directory, "Folder/Note.md"), "upper");
await fs.writeFile(path.join(directory, "Folder/note.md"), "lower");
let policyReads = 0;
let normalizationReads = 0;
const adapter = new NodeFileSystemAdapter(
directory,
undefined,
() => {
normalizationReads++;
return 1;
},
() => ++policyReads > 1
);
const files = await adapter.getFiles();
expect(files.map((file) => file.path).sort()).toEqual(["Folder/Note.md", "Folder/note.md"]);
expect(normalizationReads).toBe(1);
expect(policyReads).toBe(1);
});
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
});
@@ -253,13 +326,13 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
);
for (const name of names) await fs.writeFile(path.join(directory, child, name), "content");
const adapter = createNormalisingAdapter(directory, caseInsensitive);
const list = vi.spyOn(NodeStorageAdapter.prototype, "list");
const readDirectory = vi.spyOn(NodeStorageAdapter.prototype, "readDirectory");
try {
const files = await adapter.getFiles();
expect(files.map((file) => file.path).sort()).toEqual(names.map((name) => `${child}/${name}`).sort());
expect(list).toHaveBeenCalledTimes(3);
expect(readDirectory).toHaveBeenCalledTimes(3);
} finally {
list.mockRestore();
readDirectory.mockRestore();
}
}
);
@@ -306,12 +379,12 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
await fs.writeFile(path.join(directory, `${index}.md`), "content");
}
const adapter = createNormalisingAdapter(directory, caseInsensitive);
const list = vi.spyOn(NodeStorageAdapter.prototype, "list");
const readDirectory = vi.spyOn(NodeStorageAdapter.prototype, "readDirectory");
try {
expect(await adapter.getFiles()).toHaveLength(12);
expect(list).toHaveBeenCalledTimes(1);
expect(readDirectory).toHaveBeenCalledTimes(1);
} finally {
list.mockRestore();
readDirectory.mockRestore();
}
}
);
@@ -379,11 +452,11 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
const adapter = createNormalisingAdapter(directory);
const exactFile = {
path: nfcName as FilePath,
path: parseFilePath(nfcName),
stat: { ctime: 1, mtime: 2, size: 3, type: "file" as const },
};
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toThrow(/ambiguous/i);
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toBeInstanceOf(AmbiguousNormalisedPathError);
await expect(adapter.vault.modify(exactFile, "changed")).rejects.toThrow(/ambiguous/i);
await expect(adapter.vault.delete(exactFile)).rejects.toThrow(/ambiguous/i);
@@ -545,7 +618,8 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => {
const adapter = createNormalisingAdapter(directory);
await expect(adapter.getFiles()).resolves.toEqual([]);
await expect(adapter.storage.statExact("linked/note.md")).rejects.toThrow(/symbolic links/i);
await expect(adapter.storage.resolveLogicalPath(parseFilePath("linked/note.md"))).rejects.toThrow(/symbolic links/i);
await expect(adapter.storage.statExact("linked/note.md" as StoragePath)).rejects.toThrow(/symbolic links/i);
await expect(adapter.storage.write("linked/note.md", "changed")).rejects.toThrow(/symbolic links/i);
await expect(fs.readFile(path.join(outside, "note.md"), "utf8")).resolves.toBe("outside");
});
+15 -14
View File
@@ -1,8 +1,8 @@
import type { FilePath, UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
import type { NodeFile, NodeFolder } from "./NodeTypes";
import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import { UnicodeResolvingNodeStorageAdapter } from "./UnicodeResolvingNodeStorageAdapter";
import { NodeStorageAdapter, UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import { parseFilePath } from "@vrtmrz/livesync-commonlib/storage";
/**
* Vault adapter implementation for Node.js
@@ -56,7 +56,7 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
async rename(file: NodeFile, newPath: string): Promise<void> {
await this.storage.rename(file.path, newPath);
file.path = newPath as FilePath;
file.path = parseFilePath(newPath);
}
async delete(file: NodeFile | NodeFolder, force = false): Promise<void> {
@@ -74,16 +74,17 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
}
private async toNodeFile(path: string): Promise<NodeFile> {
const resolvedPath =
this.storage instanceof UnicodeResolvingNodeStorageAdapter
? await this.storage.resolveLogicalPath(path, undefined, false)
: path;
if (resolvedPath === null) throw new Error(`Could not read created file metadata: ${path}`);
const stat =
this.storage instanceof UnicodeResolvingNodeStorageAdapter
? await this.storage.statExact(resolvedPath)
: await this.storage.stat(resolvedPath);
const filePath = parseFilePath(path);
if (this.storage instanceof UnicodeResolvingNodeStorageAdapter) {
const resolvedPath = await this.storage.resolveLogicalPath(filePath, undefined, false);
if (resolvedPath === null) throw new Error(`Could not read created file metadata: ${path}`);
const stat = await this.storage.statExact(resolvedPath);
if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`);
return { path: resolvedPath, stat };
}
const stat = await this.storage.stat(filePath);
if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`);
return { path: resolvedPath as FilePath, stat };
return { path: filePath, stat };
}
}
@@ -1,6 +1,6 @@
import { fsPromises, os, path } from "@vrtmrz/livesync-commonlib/node";
import { describe, expect, it, vi } from "vitest";
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { parseFilePath } from "@vrtmrz/livesync-commonlib/storage";
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
import { NodeVaultAdapter } from "./NodeVaultAdapter";
@@ -11,7 +11,7 @@ describe("NodeVaultAdapter.rename", () => {
await fsPromises.writeFile(path.join(directory, "Calculus.md"), "content", "utf8");
const adapter = new NodeVaultAdapter(directory);
const file = {
path: "Calculus.md" as FilePath,
path: parseFilePath("Calculus.md"),
stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const },
};
@@ -37,7 +37,7 @@ describe("NodeVaultAdapter.rename", () => {
);
const adapter = new NodeVaultAdapter(directory);
const file = {
path: "source.md" as FilePath,
path: parseFilePath("source.md"),
stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const },
};
@@ -65,7 +65,7 @@ describe("NodeVaultAdapter.rename", () => {
);
const adapter = new NodeVaultAdapter(directory);
const file = {
path: "linked/victim.md" as FilePath,
path: parseFilePath("linked/victim.md"),
stat: { ctime: 1, mtime: 2, size: 6, type: "file" as const },
};
@@ -1,205 +1,2 @@
import { NodeStorageAdapter, validateStoragePath } from "@vrtmrz/livesync-commonlib/node";
export class AmbiguousNormalisedPathError extends Error {
constructor(path: string) {
super(`Ambiguous path after Unicode normalisation: ${path}`);
this.name = "AmbiguousNormalisedPathError";
}
}
/** Resolves NFC-equivalent logical paths against current Node storage listings. */
export class UnicodeResolvingNodeStorageAdapter extends NodeStorageAdapter {
constructor(
rootPath: string,
private readonly getPathNormalizationVersion: () => number = () => 0,
private readonly isCaseInsensitive: () => boolean = () => false
) {
super(rootPath);
}
get isUnicodePathNormalizationEnabled(): boolean {
return this.getPathNormalizationVersion() === 1;
}
/** Read metadata for an entry already resolved or obtained from a storage listing. */
async statExact(path: string) {
return await super.trystat(path);
}
/** Inspect a physical directory once before descending into its enumerated entries. */
async listExact(path: string) {
const entries = await super.list(path);
if (this.isUnicodePathNormalizationEnabled) {
const caseInsensitive = this.isCaseInsensitive();
const names = new Map<string, string>();
for (const entry of [...entries.files, ...entries.folders]) {
const key = this.comparisonKey(entry, caseInsensitive);
if (names.has(key)) throw new AmbiguousNormalisedPathError(entry);
names.set(key, entry);
}
}
return entries;
}
async resolveLogicalPath(
path: string,
caseInsensitive = this.isCaseInsensitive(),
allowMissing = true
): Promise<string | null> {
if (!this.isUnicodePathNormalizationEnabled) return path;
validateStoragePath(path, true);
if (path === "") return "";
const segments = path.split("/");
let resolvedParent = "";
for (let index = 0; index < segments.length; index++) {
const segment = segments[index];
const entries = await super.list(resolvedParent);
const candidates = [...entries.files, ...entries.folders];
const requestedKey = this.comparisonKey(segment, caseInsensitive);
const matches = candidates.filter((entryPath) => {
const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1);
return this.comparisonKey(entryName, caseInsensitive) === requestedKey;
});
if (matches.length > 1) throw new AmbiguousNormalisedPathError(path);
if (matches.length === 1) {
resolvedParent = matches[0];
continue;
}
if (!allowMissing) return null;
const remainingPath = segments.slice(index).join("/");
return resolvedParent === "" ? remainingPath : `${resolvedParent}/${remainingPath}`;
}
return resolvedParent;
}
override async exists(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<boolean> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
if (resolvedPath === null) return false;
return await super.exists(resolvedPath);
}
override async trystat(path: string, caseInsensitive = this.isCaseInsensitive()) {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
if (resolvedPath === null) return null;
return await super.trystat(resolvedPath);
}
override async stat(path: string, caseInsensitive = this.isCaseInsensitive()) {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
if (resolvedPath === null) return null;
return await super.trystat(resolvedPath);
}
override async mkdir(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<void> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
await super.mkdir(resolvedPath);
}
override async remove(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<void> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
await super.remove(resolvedPath ?? path);
}
override async read(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<string> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
return await super.read(resolvedPath ?? path);
}
override async readBinary(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<ArrayBuffer> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
return await super.readBinary(resolvedPath ?? path);
}
override async write(
path: string,
data: string,
options?: Parameters<NodeStorageAdapter["write"]>[2],
caseInsensitive = this.isCaseInsensitive()
): Promise<void> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
await super.write(resolvedPath, data, options);
}
override async writeBinary(
path: string,
data: ArrayBuffer,
options?: Parameters<NodeStorageAdapter["writeBinary"]>[2],
caseInsensitive = this.isCaseInsensitive()
): Promise<void> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
await super.writeBinary(resolvedPath, data, options);
}
override async append(
path: string,
data: string,
options?: Parameters<NodeStorageAdapter["append"]>[2],
caseInsensitive = this.isCaseInsensitive()
): Promise<void> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
await super.append(resolvedPath, data, options);
}
override async rename(
sourcePath: string,
targetPath: string,
caseInsensitive = this.isCaseInsensitive()
): Promise<void> {
const resolvedSource = await this.resolveLogicalPath(sourcePath, caseInsensitive, false);
if (resolvedSource === null) return await super.rename(sourcePath, targetPath);
const resolvedTarget = await this.resolveLogicalPath(targetPath, caseInsensitive, true);
if (resolvedTarget === null) throw new Error(`Could not resolve path: ${targetPath}`);
// Preserve explicit case-only or Unicode-spelling renames when the target resolves to the source.
const sourceKey = this.comparisonKey(resolvedSource, caseInsensitive);
const targetKey = this.comparisonKey(resolvedTarget, caseInsensitive);
const target =
sourceKey === targetKey && resolvedTarget === resolvedSource && targetPath !== resolvedSource
? await this.resolveRenameTargetSpelling(targetPath, caseInsensitive)
: resolvedTarget;
await super.rename(resolvedSource, target);
}
override async list(basePath: string, caseInsensitive = this.isCaseInsensitive()) {
const resolvedPath = await this.resolveLogicalPath(basePath, caseInsensitive, false);
if (resolvedPath === null) return { files: [], folders: [] };
return await super.list(resolvedPath);
}
private async resolveRenameTargetSpelling(path: string, caseInsensitive: boolean): Promise<string> {
validateStoragePath(path, false);
const segments = path.split("/");
const leaf = segments.pop();
if (leaf === undefined) throw new Error(`Could not resolve path: ${path}`);
const parentPath = segments.join("/");
const resolvedParent = await this.resolveLogicalPath(parentPath, caseInsensitive, true);
if (resolvedParent === null) throw new Error(`Could not resolve path: ${path}`);
const entries = await super.list(resolvedParent);
const requestedKey = this.comparisonKey(leaf, caseInsensitive);
const matches = [...entries.files, ...entries.folders].filter((entryPath) => {
const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1);
return this.comparisonKey(entryName, caseInsensitive) === requestedKey;
});
if (matches.length > 1) throw new AmbiguousNormalisedPathError(path);
return resolvedParent === "" ? leaf : `${resolvedParent}/${leaf}`;
}
private comparisonKey(segment: string, caseInsensitive: boolean): string {
const normalisedSegment = segment.normalize("NFC");
return caseInsensitive ? normalisedSegment.toLowerCase() : normalisedSegment;
}
}
export { UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
export { AmbiguousNormalisedPathError } from "@vrtmrz/livesync-commonlib/storage";