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

+23
View File
@@ -0,0 +1,23 @@
export async function findCaseInsensitiveFile<T extends object>(
requestedPath: string,
findExact: () => Promise<T | null>,
cachedFiles: ReadonlyMap<string, T>,
scan: () => Promise<void>
): Promise<T | null> {
const exact = await findExact();
if (exact) return exact;
const lowerPath = requestedPath.toLowerCase();
const findCached = (): T | null => {
for (const [cachedPath, cachedFile] of cachedFiles) {
if (cachedPath.toLowerCase() === lowerPath) return cachedFile;
}
return null;
};
const cached = findCached();
if (cached) return cached;
await scan();
return findCached();
}
+7 -21
View File
@@ -8,6 +8,7 @@ import { NodeVaultAdapter } from "./NodeVaultAdapter";
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";
/**
* Complete file system adapter implementation for Node.js
@@ -87,27 +88,12 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
return await this.refreshResolvedFile(resolvedPath);
}
const exact = await this.getAbstractFileByPath(pathStr);
if (exact) {
return exact;
}
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<NodeFile[]> {
@@ -124,6 +124,83 @@ describe("NodeFileSystemAdapter file enumeration", () => {
});
});
describe("NodeFileSystemAdapter case-insensitive path lookup", () => {
const tempDirs: string[] = [];
async function createVault(files: string[] = []) {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-cli-insensitive-"));
tempDirs.push(directory);
for (const file of files) {
await fs.mkdir(path.dirname(path.join(directory, file)), { recursive: true });
await fs.writeFile(path.join(directory, file), `content of ${file}`);
}
return { directory, adapter: new NodeFileSystemAdapter(directory) };
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
});
it("returns an exact path without scanning the directory", async () => {
const { adapter } = await createVault(["Calculus.md"]);
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
await expect(adapter.getAbstractFileByPathInsensitive("Calculus.md")).resolves.toMatchObject({
path: "Calculus.md",
});
expect(scanDirectory).not.toHaveBeenCalled();
});
it("returns a cached case-insensitive match without rescanning", async () => {
const { adapter } = await createVault(["Calculus.md"]);
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 { adapter } = await createVault(["Calculus.md"]);
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 { adapter } = await createVault(["other.md"]);
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
expect(scanDirectory).toHaveBeenCalledOnce();
});
it("returns null and reports a directory scan failure", async () => {
const missingDirectory = path.join(os.tmpdir(), `livesync-cli-insensitive-missing-${Date.now()}`);
const reportDiagnostic = vi.fn();
const adapter = new NodeFileSystemAdapter(missingDirectory, reportDiagnostic);
const scanDirectory = vi.spyOn(adapter, "scanDirectory");
await expect(adapter.getAbstractFileByPathInsensitive("missing.md")).resolves.toBeNull();
expect(scanDirectory).toHaveBeenCalledOnce();
expect(reportDiagnostic).toHaveBeenCalledWith(
expect.stringContaining("Error scanning directory"),
expect.any(Error)
);
});
});
describe("NodeFileSystemAdapter Unicode path resolution", () => {
const tempDirs: string[] = [];
@@ -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,
@@ -15,30 +14,7 @@ import { watch as chokidarWatch, type FSWatcher } from "chokidar";
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
import { fsPromises as fs, getNodeFileCreationTime, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
/**
* CLI-specific type guard adapter
*/
class CLITypeGuardAdapter implements IStorageEventTypeGuardAdapter<NodeFile, NodeFolder> {
isFile(file: unknown): file is NodeFile {
return !!(
file &&
typeof file === "object" &&
"path" in file &&
"stat" in file &&
!(file as { isFolder?: boolean }).isFolder
);
}
isFolder(item: unknown): item is NodeFolder {
return !!(
item &&
typeof item === "object" &&
"path" in item &&
(item as { isFolder?: boolean }).isFolder === true
);
}
}
import { NodeTypeGuardAdapter } from "@/apps/cli/adapters/NodeTypeGuardAdapter";
/**
* CLI-specific persistence adapter (file-based snapshot)
@@ -211,7 +187,7 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
* Composite adapter for CLI StorageEventManager
*/
export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapter<NodeFile, NodeFolder> {
readonly typeGuard: CLITypeGuardAdapter;
readonly typeGuard: NodeTypeGuardAdapter;
readonly persistence: CLIPersistenceAdapter;
readonly watch: CLIWatchAdapter;
readonly status: CLIStatusAdapter;
@@ -223,7 +199,7 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
watchEnabled: boolean = false,
reportDiagnostic: CliDiagnosticReporter = () => undefined
) {
this.typeGuard = new CLITypeGuardAdapter();
this.typeGuard = new NodeTypeGuardAdapter();
this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic);
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic);
this.status = new CLIStatusAdapter();
@@ -201,3 +201,27 @@ describe("CLIStorageEventManagerAdapter", () => {
processExitSpy.mockRestore();
});
});
describe("CLIStorageEventManagerAdapter type guard", () => {
const typeGuard = new CLIStorageEventManagerAdapter("/base").typeGuard;
it.each([
{ label: "null", value: null, expected: false },
{ label: "an object without a path", value: { stat: {} }, expected: false },
{ label: "an object without a stat", value: { path: "note.md" }, expected: false },
{ label: "a file-shaped object", value: { path: "note.md", stat: {} }, expected: true },
{ label: "a folder-shaped object", value: { path: "folder", 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 }, expected: false },
{ label: "an object without the folder marker", value: { path: "folder" }, expected: false },
{ label: "a folder-shaped object", value: { path: "folder", isFolder: true }, expected: true },
{ label: "a file-shaped object", value: { path: "note.md", stat: {} }, expected: false },
])("classifies $label as a folder", ({ value, expected }) => {
expect(typeGuard.isFolder(value)).toBe(expected);
});
});
@@ -1,6 +1,4 @@
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
@@ -10,7 +8,7 @@ import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
import { StorageEventManagerCLI } from "@/apps/cli/managers/StorageEventManagerCLI";
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import type { IgnoreRules } from "./IgnoreRules";
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
/**
* Initialize service modules for CLI version
@@ -82,40 +80,7 @@ export function initialiseServiceModulesCLI(
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,
@@ -70,6 +70,21 @@ Deno.test("CLI file operations: push / cat / ls / info / rm / resolve / cat-rev
console.log("[PASS] push/cat roundtrip matched");
});
await t.step("push/pull binary roundtrip", async () => {
const source = workDir.join("binary-source.bin");
const restored = workDir.join("binary-restored.bin");
const remotePath = "test/blob-roundtrip.bin";
const content = Uint8Array.from({ length: 1025 }, (_, index) => index % 256);
await Deno.writeFile(source, content);
await run("push", source, remotePath);
await run("pull", remotePath, restored);
assertEquals(await Deno.readFile(restored), content, "Binary push/pull must preserve every byte");
const info = JSON.parse(await run("info", remotePath));
assertEquals(info.size, content.byteLength, "Binary Metadata must retain the original byte length");
});
// ------------------------------------------------------------------
// ls: single file
// ------------------------------------------------------------------
@@ -0,0 +1,50 @@
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import { ServiceRebuilder } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
/** Compose the service modules shared by app hosts after their storage setup. */
export function composeSharedServiceModules(
services: InjectableServiceHub<ServiceContext>,
databaseFileAccess: DatabaseFileAccess,
storageAccess: StorageAccess
): Pick<ServiceModules, "fileHandler" | "rebuilder"> {
const fileHandler = new ServiceFileHandler({
events: services.context.events,
API: services.API,
databaseFileAccess,
conflict: services.conflict,
setting: services.setting,
fileProcessing: services.fileProcessing,
vault: services.vault,
path: services.path,
replication: services.replication,
storageAccess,
fileReflectionProvenance: createFileReflectionProvenance(services.keyValueDB),
});
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,
fileProcessing: services.fileProcessing,
storageAccess,
control: services.control,
});
return { rebuilder, fileHandler };
}
@@ -0,0 +1,379 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { initialiseServiceModulesCLI } from "@/apps/cli/serviceModules/CLIServiceModules";
import { initialiseServiceModulesFSAPI } from "@/apps/webapp/serviceModules/FSAPIServiceModules";
import { FILE_REFLECTION_PROVENANCE_STORE } from "@/serviceModules/FileReflectionProvenance";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
const composition = vi.hoisted(() => ({
calls: [] as string[],
dependencies: {} as Record<string, unknown>,
instances: {} as Record<string, unknown>,
capture(name: string, dependencies: unknown, instance: unknown) {
this.calls.push(name);
this.dependencies[name] = dependencies;
this.instances[name] = instance;
},
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager", () => ({
StorageAccessManager: class {
constructor() {
composition.capture("StorageAccessManager", undefined, this);
}
},
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder", () => ({
ServiceRebuilder: class {
constructor(dependencies: unknown) {
composition.capture("ServiceRebuilder", dependencies, this);
}
},
}));
vi.mock("@/serviceModules/FileHandler", () => ({
ServiceFileHandler: class {
constructor(dependencies: unknown) {
composition.capture("ServiceFileHandler", dependencies, this);
}
},
}));
vi.mock("@/apps/cli/serviceModules/FileAccessCLI", () => ({
FileAccessCLI: class {
constructor(...args: unknown[]) {
composition.capture("FileAccessCLI", args, this);
}
},
}));
vi.mock("@/apps/cli/serviceModules/ServiceFileAccessImpl", () => ({
ServiceFileAccessCLI: class {
constructor(dependencies: unknown) {
composition.capture("ServiceFileAccessCLI", dependencies, this);
}
},
}));
vi.mock("@/apps/cli/serviceModules/DatabaseFileAccess", () => ({
ServiceDatabaseFileAccessCLI: class {
constructor(dependencies: unknown) {
composition.capture("ServiceDatabaseFileAccessCLI", dependencies, this);
}
},
}));
vi.mock("@/apps/cli/managers/StorageEventManagerCLI", () => ({
StorageEventManagerCLI: class {
close = vi.fn(async () => undefined);
constructor(...args: unknown[]) {
composition.capture("StorageEventManagerCLI", args, this);
}
},
}));
vi.mock("@/apps/webapp/serviceModules/FileAccessFSAPI", () => ({
FileAccessFSAPI: class {
constructor(...args: unknown[]) {
composition.capture("FileAccessFSAPI", args, this);
}
},
}));
vi.mock("@/apps/webapp/serviceModules/ServiceFileAccessImpl", () => ({
ServiceFileAccessFSAPI: class {
constructor(dependencies: unknown) {
composition.capture("ServiceFileAccessFSAPI", dependencies, this);
}
},
}));
vi.mock("@/apps/webapp/serviceModules/DatabaseFileAccess", () => ({
ServiceDatabaseFileAccessFSAPI: class {
constructor(dependencies: unknown) {
composition.capture("ServiceDatabaseFileAccessFSAPI", dependencies, this);
}
},
}));
vi.mock("@/apps/webapp/managers/StorageEventManagerFSAPI", () => ({
StorageEventManagerFSAPI: class {
constructor(...args: unknown[]) {
composition.capture("StorageEventManagerFSAPI", args, this);
}
},
}));
function createServices() {
const hostServices = Object.fromEntries(
[
"API",
"conflict",
"control",
"database",
"databaseEvents",
"fileProcessing",
"path",
"remote",
"replication",
"replicator",
"setting",
"UI",
"vault",
].map((name) => [name, { name }])
);
const events = { label: "host event hub" };
const addUnloadHandler = vi.fn<(handler: () => Promise<boolean>) => void>();
const openSimpleStore = vi.fn((name: string) => {
composition.calls.push("openSimpleStore");
composition.dependencies.openSimpleStore = name;
return { name };
});
return {
...hostServices,
appLifecycle: { onUnload: { addHandler: addUnloadHandler } },
context: { events },
events,
keyValueDB: { openSimpleStore },
addUnloadHandler,
};
}
function createCore() {
return {} as LiveSyncBaseCore<ServiceContext, never>;
}
beforeEach(() => {
composition.calls.length = 0;
composition.dependencies = {};
composition.instances = {};
});
describe("app service module composition", () => {
it("preserves CLI host construction, shared wiring, provenance, and watcher shutdown", async () => {
const services = createServices();
const core = createCore();
const modules = initialiseServiceModulesCLI(
"/vault",
core,
services as unknown as InjectableServiceHub<ServiceContext>
);
expect(composition.calls).toEqual([
"StorageAccessManager",
"FileAccessCLI",
"StorageEventManagerCLI",
"ServiceFileAccessCLI",
"ServiceDatabaseFileAccessCLI",
"openSimpleStore",
"ServiceFileHandler",
"ServiceRebuilder",
]);
expect(composition.dependencies.openSimpleStore).toBe(FILE_REFLECTION_PROVENANCE_STORE);
const [basePath, fileAccessOptions] = composition.dependencies.FileAccessCLI as [
string,
Record<string, unknown>,
];
const [eventBasePath, eventCore, eventDependencies, ignoreRules, watchEnabled] = composition.dependencies
.StorageEventManagerCLI as [string, unknown, Record<string, unknown>, unknown, boolean];
const storageAccessDependencies = composition.dependencies.ServiceFileAccessCLI as Record<string, unknown>;
const databaseAccessDependencies = composition.dependencies.ServiceDatabaseFileAccessCLI as Record<
string,
unknown
>;
const fileHandlerDependencies = composition.dependencies.ServiceFileHandler as Record<string, unknown>;
const rebuilderDependencies = composition.dependencies.ServiceRebuilder as Record<string, unknown>;
expect(basePath).toBe("/vault");
expect(fileAccessOptions).toEqual({
storageAccessManager: composition.instances.StorageAccessManager,
vaultService: services.vault,
settingService: services.setting,
APIService: services.API,
pathService: services.path,
});
expect(eventBasePath).toBe("/vault");
expect(eventCore).toBe(core);
expect(eventDependencies).toEqual({
fileProcessing: services.fileProcessing,
setting: services.setting,
vaultService: services.vault,
storageAccessManager: composition.instances.StorageAccessManager,
APIService: services.API,
});
expect(ignoreRules).toBeUndefined();
expect(watchEnabled).toBe(false);
expect(storageAccessDependencies).toEqual({
API: services.API,
setting: services.setting,
fileProcessing: services.fileProcessing,
vault: services.vault,
appLifecycle: services.appLifecycle,
storageEventManager: composition.instances.StorageEventManagerCLI,
storageAccessManager: composition.instances.StorageAccessManager,
vaultAccess: composition.instances.FileAccessCLI,
});
expect(databaseAccessDependencies).toEqual({
events: services.events,
API: services.API,
database: services.database,
path: services.path,
storageAccess: modules.storageAccess,
vault: services.vault,
});
expect(modules.databaseFileAccess).toBe(composition.instances.ServiceDatabaseFileAccessCLI);
expect(modules.fileHandler).toBe(composition.instances.ServiceFileHandler);
expect(modules.rebuilder).toBe(composition.instances.ServiceRebuilder);
expect(fileHandlerDependencies).toEqual({
events: services.events,
API: services.API,
databaseFileAccess: modules.databaseFileAccess,
conflict: services.conflict,
setting: services.setting,
fileProcessing: services.fileProcessing,
vault: services.vault,
path: services.path,
replication: services.replication,
storageAccess: modules.storageAccess,
fileReflectionProvenance: expect.any(Object),
});
expect(rebuilderDependencies).toEqual({
path: services.path,
events: services.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: modules.fileHandler,
fileProcessing: services.fileProcessing,
storageAccess: modules.storageAccess,
control: services.control,
});
const unloadHandler = services.addUnloadHandler.mock.calls[0]?.[0];
expect(unloadHandler).toBeDefined();
await expect(unloadHandler?.()).resolves.toBe(true);
expect(
(composition.instances.StorageEventManagerCLI as { close: ReturnType<typeof vi.fn> }).close
).toHaveBeenCalledOnce();
});
it("preserves WebApp host construction and returns its vault access and event manager", () => {
const services = createServices();
const core = createCore();
const rootHandle = {} as FileSystemDirectoryHandle;
const modules = initialiseServiceModulesFSAPI(
rootHandle,
core,
services as unknown as InjectableServiceHub<ServiceContext>
);
expect(composition.calls).toEqual([
"StorageAccessManager",
"FileAccessFSAPI",
"StorageEventManagerFSAPI",
"ServiceFileAccessFSAPI",
"ServiceDatabaseFileAccessFSAPI",
"openSimpleStore",
"ServiceFileHandler",
"ServiceRebuilder",
]);
expect(composition.dependencies.openSimpleStore).toBe(FILE_REFLECTION_PROVENANCE_STORE);
const [fileAccessRoot, fileAccessOptions] = composition.dependencies.FileAccessFSAPI as [
FileSystemDirectoryHandle,
Record<string, unknown>,
];
const [eventRoot, eventCore, eventDependencies] = composition.dependencies.StorageEventManagerFSAPI as [
FileSystemDirectoryHandle,
unknown,
Record<string, unknown>,
];
const storageAccessDependencies = composition.dependencies.ServiceFileAccessFSAPI as Record<string, unknown>;
const databaseAccessDependencies = composition.dependencies.ServiceDatabaseFileAccessFSAPI as Record<
string,
unknown
>;
expect(fileAccessRoot).toBe(rootHandle);
expect(fileAccessOptions).toEqual({
storageAccessManager: composition.instances.StorageAccessManager,
vaultService: services.vault,
settingService: services.setting,
APIService: services.API,
pathService: services.path,
});
expect(eventRoot).toBe(rootHandle);
expect(eventCore).toBe(core);
expect(eventDependencies).toEqual({
fileProcessing: services.fileProcessing,
setting: services.setting,
vaultService: services.vault,
storageAccessManager: composition.instances.StorageAccessManager,
APIService: services.API,
});
expect(storageAccessDependencies).toEqual({
API: services.API,
setting: services.setting,
fileProcessing: services.fileProcessing,
vault: services.vault,
appLifecycle: services.appLifecycle,
storageEventManager: composition.instances.StorageEventManagerFSAPI,
storageAccessManager: composition.instances.StorageAccessManager,
vaultAccess: composition.instances.FileAccessFSAPI,
});
expect(databaseAccessDependencies).toEqual({
events: services.events,
API: services.API,
database: services.database,
path: services.path,
storageAccess: modules.storageAccess,
vault: services.vault,
});
expect(modules.vaultAccess).toBe(composition.instances.FileAccessFSAPI);
expect(modules.storageEventManager).toBe(composition.instances.StorageEventManagerFSAPI);
expect(composition.dependencies.ServiceFileHandler).toEqual({
events: services.events,
API: services.API,
databaseFileAccess: modules.databaseFileAccess,
conflict: services.conflict,
setting: services.setting,
fileProcessing: services.fileProcessing,
vault: services.vault,
path: services.path,
replication: services.replication,
storageAccess: modules.storageAccess,
fileReflectionProvenance: expect.any(Object),
});
expect(composition.dependencies.ServiceRebuilder).toEqual({
path: services.path,
events: services.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: modules.fileHandler,
fileProcessing: services.fileProcessing,
storageAccess: modules.storageAccess,
control: services.control,
});
});
});
@@ -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,
+1 -1
View File
@@ -145,7 +145,7 @@ export class WebPeerRuntime {
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
this.menu?.hide();
this.menu = new Menu()
this.menu = new Menu(this.services.browserUi)
.addItem((item) =>
item.setTitle("📥 Only fetch").onClick(async () => {
await this.p2p.targetedTransfer.pullFromPeer(peer.peerId);