Share application adapter and diagnostic helpers

This commit is contained in:
vorotamoroz committed 2026-10-29 15:04:09 +00:00
1 parent 4027417bf5
commit b9dcb0d4c7
26 files changed
+937 -305

No files matched your search

@@ -8,6 +8,7 @@ import { FSAPIVaultAdapter } from "./FSAPIVaultAdapter";
import type { FSAPIFile, FSAPIFolder, FSAPIStat } from "./FSAPITypes";
import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2";
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
/**
* Complete file system adapter implementation for FileSystem API
@@ -85,28 +86,12 @@ export class FSAPIFileSystemAdapter implements IFileSystemAdapter<FSAPIFile, FSA
*/
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<FSAPIFile | null> {
const pathStr = this.normalisePath(p);
const exact = await this.getAbstractFileByPath(pathStr);
if (exact) {
return exact;
}
// TODO: Refactor: Very, Very heavy.
const lowerPath = pathStr.toLowerCase();
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
if (cachedPath.toLowerCase() === lowerPath) {
return cachedFile;
}
}
await this.scanDirectory();
for (const [cachedPath, cachedFile] of this.fileCache.entries()) {
if (cachedPath.toLowerCase() === lowerPath) {
return cachedFile;
}
}
return null;
return await findCaseInsensitiveFile(
pathStr,
() => this.getAbstractFileByPath(pathStr),
this.fileCache,
() => this.scanDirectory()
);
}
async getFiles(): Promise<FSAPIFile[]> {
@@ -110,6 +110,83 @@ describe("FileSystemAccessStorageAdapter", () => {
}
});
describe("FSAPIVaultAdapter creation", () => {
it.each(["text", "binary"] as const)(
"creates nested %s files with exact content and stored metadata",
async (kind) => {
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
const adapter = new FSAPIVaultAdapter(root);
const data = new Uint8Array([0, 255, 1, 128, 42]).buffer;
const path = "/nested//folder/file.dat";
const file =
kind === "text" ? await adapter.create(path, "日本語😀") : await adapter.createBinary(path, data);
const expected = kind === "text" ? new TextEncoder().encode("日本語😀") : new Uint8Array(data);
const nested = await root.getDirectoryHandle("nested");
const folder = await nested.getDirectoryHandle("folder");
const stored = await folder.getFileHandle("file.dat");
expect(file.path).toBe(path);
expect(file.handle).toBe(stored);
expect(new Uint8Array(await adapter.readBinary(file))).toEqual(expected);
expect(file.stat).toEqual({ size: expected.byteLength, mtime: 1, ctime: 1, type: "file" });
}
);
it.each(["text", "binary"] as const)("waits for the %s writer to close before reading metadata", async (kind) => {
let releaseClose!: () => void;
const closed = new Promise<void>((resolve) => {
releaseClose = resolve;
});
const events: string[] = [];
const getFile = vi.fn(async () => {
events.push("metadata");
return new File(["result"], "file.dat", { lastModified: 123 });
});
const close = vi.fn(async () => {
events.push("close-start");
await closed;
events.push("closed");
});
const write = vi.fn(async () => {
events.push("write");
});
const fileHandle = { createWritable: vi.fn(async () => ({ write, close })), getFile };
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
const adapter = new FSAPIVaultAdapter(root);
const data = new Uint8Array([0, 255]).buffer;
const pending = kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", data);
try {
await vi.waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(getFile).not.toHaveBeenCalled();
} finally {
releaseClose();
}
const file = await pending;
expect(events).toEqual(["write", "close-start", "closed", "metadata"]);
expect(write).toHaveBeenCalledWith(kind === "text" ? "text" : data);
expect(file.stat).toEqual({ size: 6, ctime: 123, mtime: 123, type: "file" });
});
it.each(["text", "binary"] as const)("propagates a %s write failure without returning metadata", async (kind) => {
const failure = new Error("fixture write failed");
const getFile = vi.fn();
const fileHandle = {
createWritable: vi.fn(async () => ({
write: vi.fn(async () => {
throw failure;
}),
close: vi.fn(),
})),
getFile,
};
const root = { getFileHandle: vi.fn(async () => fileHandle) } as unknown as FileSystemDirectoryHandle;
const adapter = new FSAPIVaultAdapter(root);
const pending =
kind === "text" ? adapter.create("file.dat", "text") : adapter.createBinary("file.dat", new ArrayBuffer(2));
await expect(pending).rejects.toBe(failure);
expect(getFile).not.toHaveBeenCalled();
});
});
describe("FSAPIVaultAdapter.rename", () => {
it("moves the file through a temporary copy while preserving content", async () => {
const memoryRoot = new MemoryDirectoryHandle("root");
@@ -142,6 +219,20 @@ describe("FSAPIVaultAdapter.rename", () => {
});
describe("FSAPIFileSystemAdapter path case", () => {
it("returns an exact path without scanning the directory", async () => {
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
const vault = new FSAPIVaultAdapter(root);
await vault.create("Calculus.md", "content");
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
await expect(adapter.getAbstractFileByPathInsensitive("Calculus.md")).resolves.toMatchObject({
path: "Calculus.md",
});
expect(scanDirectory).not.toHaveBeenCalled();
});
it("returns the stored path case from a case-insensitive file system", async () => {
const memoryRoot = new MemoryDirectoryHandle("root", true);
const root = memoryRoot as unknown as FileSystemDirectoryHandle;
@@ -155,6 +246,48 @@ describe("FSAPIFileSystemAdapter path case", () => {
);
});
it("returns a cached case-insensitive match without rescanning", async () => {
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
const vault = new FSAPIVaultAdapter(root);
await vault.create("Calculus.md", "content");
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
await adapter.scanDirectory();
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
path: "Calculus.md",
});
expect(scanDirectory).not.toHaveBeenCalled();
});
it("rescans once and returns a newly cached case-insensitive match", async () => {
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
const vault = new FSAPIVaultAdapter(root);
await vault.create("Calculus.md", "content");
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
await expect(adapter.getAbstractFileByPathInsensitive("calculus.md")).resolves.toMatchObject({
path: "Calculus.md",
});
expect(scanDirectory).toHaveBeenCalledOnce();
await expect(adapter.getAbstractFileByPath("Calculus.md")).resolves.toMatchObject({ path: "Calculus.md" });
});
it("returns null after one scan when no cached path matches", async () => {
const root = new MemoryDirectoryHandle("root") as unknown as FileSystemDirectoryHandle;
const vault = new FSAPIVaultAdapter(root);
await vault.create("other.md", "content");
const adapter = new FSAPIFileSystemAdapter(root, vi.fn());
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
expect(scanDirectory).toHaveBeenCalledOnce();
});
it("reports scan failures through the injected Webapp log", async () => {
const root = {
name: "root",
@@ -173,4 +306,25 @@ describe("FSAPIFileSystemAdapter path case", () => {
"fsapi-scan"
);
});
it("returns null and reports scan failure during case-insensitive lookup", async () => {
const root = {
name: "root",
async *entries(): AsyncIterableIterator<[string, FileSystemHandle]> {
throw new Error("scan failed");
},
} as unknown as FileSystemDirectoryHandle;
const addLog = vi.fn();
const adapter = new FSAPIFileSystemAdapter(root, addLog);
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
expect(scanDirectory).toHaveBeenCalledOnce();
expect(addLog).toHaveBeenCalledWith(
"Error scanning directory '.': Error: scan failed",
LOG_LEVEL_NOTICE,
"fsapi-scan"
);
});
});
+5 -28
View File
@@ -36,37 +36,14 @@ export class FSAPIVaultAdapter implements IVaultAdapter<FSAPIFile> {
}
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<FSAPIFile> {
const parts = p.split("/").filter((part) => part !== "");
const fileName = parts[parts.length - 1];
// Navigate to parent directory, creating as needed
let currentHandle = this.rootHandle;
for (let i = 0; i < parts.length - 1; i++) {
currentHandle = await currentHandle.getDirectoryHandle(parts[i], { create: true });
}
// Create the file
const fileHandle = await currentHandle.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
// Get file metadata
const fileObject = await fileHandle.getFile();
return {
path: p as FilePath,
stat: {
size: fileObject.size,
mtime: fileObject.lastModified,
ctime: fileObject.lastModified,
type: "file",
},
handle: fileHandle,
};
return await this.createFile(p, data);
}
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<FSAPIFile> {
return await this.createFile(p, data);
}
private async createFile(p: string, data: string | ArrayBuffer): Promise<FSAPIFile> {
const parts = p.split("/").filter((part) => part !== "");
const fileName = parts[parts.length - 1];
@@ -2,7 +2,6 @@ import type { FilePath, UXFileInfoStub, UXInternalFileInfoStub } from "@vrtmrz/l
import type { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
import type {
IStorageEventTypeGuardAdapter,
IStorageEventPersistenceAdapter,
IStorageEventWatchAdapter,
IStorageEventStatusAdapter,
@@ -14,6 +13,7 @@ import type { FSAPIFile, FSAPIFolder } from "@/apps/webapp/adapters/FSAPITypes";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
import { FSAPITypeGuardAdapter } from "@/apps/webapp/adapters/FSAPITypeGuardAdapter";
type FileSystemObserverRecord = {
changedHandle?: FileSystemFileHandle | FileSystemDirectoryHandle;
@@ -34,32 +34,6 @@ type GlobalWithFileSystemObserver = typeof compatGlobal & {
FileSystemObserver?: FileSystemObserverConstructor;
};
/**
* FileSystem API-specific type guard adapter
*/
class FSAPITypeGuardAdapter implements IStorageEventTypeGuardAdapter<FSAPIFile, FSAPIFolder> {
isFile(file: unknown): file is FSAPIFile {
return !!(
file &&
typeof file === "object" &&
"path" in file &&
"stat" in file &&
"handle" in file &&
!(file as { isFolder?: boolean }).isFolder
);
}
isFolder(item: unknown): item is FSAPIFolder {
return !!(
item &&
typeof item === "object" &&
"path" in item &&
(item as { isFolder?: boolean }).isFolder === true &&
"handle" in item
);
}
}
/**
* FileSystem API-specific persistence adapter (IndexedDB-based snapshot)
*/
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from "vitest";
import { FSAPIStorageEventManagerAdapter } from "./FSAPIStorageEventManagerAdapter";
describe("FSAPIStorageEventManagerAdapter type guard", () => {
const typeGuard = new FSAPIStorageEventManagerAdapter({} as FileSystemDirectoryHandle, vi.fn()).typeGuard;
it.each([
{ label: "null", value: null, expected: false },
{ label: "an object without a path", value: { stat: {}, handle: undefined }, expected: false },
{ label: "an object without a stat", value: { path: "note.md", handle: undefined }, expected: false },
{ label: "an object without a handle", value: { path: "note.md", stat: {} }, expected: false },
{
label: "a file-shaped object with a handle property",
value: { path: "note.md", stat: {}, handle: undefined },
expected: true,
},
{
label: "a folder-shaped object",
value: { path: "folder", handle: undefined, isFolder: true },
expected: false,
},
])("classifies $label as a file", ({ value, expected }) => {
expect(typeGuard.isFile(value)).toBe(expected);
});
it.each([
{ label: "null", value: null, expected: false },
{ label: "an object without a path", value: { isFolder: true, handle: undefined }, expected: false },
{ label: "an object without the folder marker", value: { path: "folder", handle: undefined }, expected: false },
{ label: "an object without a handle", value: { path: "folder", isFolder: true }, expected: false },
{
label: "a folder-shaped object with a handle property",
value: { path: "folder", isFolder: true, handle: undefined },
expected: true,
},
{ label: "a file-shaped object", value: { path: "note.md", stat: {}, handle: undefined }, expected: false },
])("classifies $label as a folder", ({ value, expected }) => {
expect(typeGuard.isFolder(value)).toBe(expected);
});
});
@@ -1,5 +1,4 @@
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
@@ -9,8 +8,7 @@ import { ServiceFileAccessFSAPI } from "./ServiceFileAccessImpl";
import { ServiceDatabaseFileAccessFSAPI } from "./DatabaseFileAccess";
import { StorageEventManagerFSAPI } from "@/apps/webapp/managers/StorageEventManagerFSAPI";
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
export interface FSAPIServiceModules extends ServiceModules {
vaultAccess: FileAccessFSAPI;
@@ -73,40 +71,7 @@ export function initialiseServiceModulesFSAPI(
vault: services.vault,
});
// File handler (platform-independent)
const fileHandler = new ServiceFileHandler({
events: services.context.events,
API: services.API,
databaseFileAccess: databaseFileAccess,
conflict: services.conflict,
setting: services.setting,
fileProcessing: services.fileProcessing,
vault: services.vault,
path: services.path,
replication: services.replication,
storageAccess: storageAccess,
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
});
// Rebuilder (platform-independent)
const rebuilder = new ServiceRebuilder({
path: services.path,
events: services.context.events,
API: services.API,
database: services.database,
appLifecycle: services.appLifecycle,
setting: services.setting,
remote: services.remote,
databaseEvents: services.databaseEvents,
replication: services.replication,
replicator: services.replicator,
UI: services.UI,
vault: services.vault,
fileHandler: fileHandler,
fileProcessing: services.fileProcessing,
storageAccess: storageAccess,
control: services.control,
});
const { fileHandler, rebuilder } = composeSharedServiceModules(services, databaseFileAccess, storageAccess);
return {
rebuilder,