diff --git a/devs.md b/devs.md index a9acec7c..034df69c 100644 --- a/devs.md +++ b/devs.md @@ -158,7 +158,7 @@ Commonlib's `targetFilter.ts` and `prepareDatabaseForUse.ts` demonstrate the int Use interaction-based, London School unit tests for the composition boundary. Verify collaborator calls, ordering, failure short-circuiting, and handler registration, then test the focused state owner for its transitions and invariants. If a test needs a broad core fixture, a large class mock, deep mock chains, or unrelated Services, treat that friction as a design-review signal and consider a private context with narrower functions before adding more test machinery. -See [Service feature and legacy Module boundaries](docs/design_docs/service_feature_and_legacy_module_boundaries.md) for the selection criteria, current examples, reasons to avoid new `AbstractModule` subclasses, incremental migration guidance, and test shapes. Commonlib's [service feature composition guide](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/service-feature-composition.md) defines the shared host-neutral boundary. +See [Service feature and legacy Module boundaries](docs/design_docs/service_feature_and_legacy_module_boundaries.md) for the selection criteria, current examples, reasons to avoid new `AbstractModule` subclasses, incremental migration guidance, and test shapes. Commonlib's [service feature composition guide](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/service-feature-composition.md) defines the shared host-neutral boundary. Fancy Kit's [shared runtime composition guide](https://github.com/vrtmrz/fancy-kit/blob/main/docs/runtime-composition.md) maps content application, cleanup, progress, and host adapters to their owning APIs, with their unreleased status and consumer responsibilities. Legacy Modules remain grouped by directory: diff --git a/src/apps/caseInsensitiveFileLookup.ts b/src/apps/caseInsensitiveFileLookup.ts new file mode 100644 index 00000000..9be18f36 --- /dev/null +++ b/src/apps/caseInsensitiveFileLookup.ts @@ -0,0 +1,23 @@ +export async function findCaseInsensitiveFile( + requestedPath: string, + findExact: () => Promise, + cachedFiles: ReadonlyMap, + scan: () => Promise +): Promise { + 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(); +} diff --git a/src/apps/cli/adapters/NodeFileSystemAdapter.ts b/src/apps/cli/adapters/NodeFileSystemAdapter.ts index 6cb8f458..9ba123e4 100644 --- a/src/apps/cli/adapters/NodeFileSystemAdapter.ts +++ b/src/apps/cli/adapters/NodeFileSystemAdapter.ts @@ -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 this.getAbstractFileByPath(pathStr), + this.fileCache, + () => this.scanDirectory() + ); } async getFiles(): Promise { diff --git a/src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts b/src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts index 7d0fbaae..1791f6d5 100644 --- a/src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts +++ b/src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts @@ -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[] = []; diff --git a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts index e6369fce..f4485044 100644 --- a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts +++ b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts @@ -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 { - 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 { - 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(); diff --git a/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts b/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts index bf8f6712..d294e817 100644 --- a/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts +++ b/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts @@ -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); + }); +}); diff --git a/src/apps/cli/serviceModules/CLIServiceModules.ts b/src/apps/cli/serviceModules/CLIServiceModules.ts index 29b9df90..53cdf12f 100644 --- a/src/apps/cli/serviceModules/CLIServiceModules.ts +++ b/src/apps/cli/serviceModules/CLIServiceModules.ts @@ -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, diff --git a/src/apps/cli/testdeno/test-setup-put-cat.ts b/src/apps/cli/testdeno/test-setup-put-cat.ts index 1df861e4..86f04d9e 100644 --- a/src/apps/cli/testdeno/test-setup-put-cat.ts +++ b/src/apps/cli/testdeno/test-setup-put-cat.ts @@ -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 // ------------------------------------------------------------------ diff --git a/src/apps/serviceModules/composeSharedServiceModules.ts b/src/apps/serviceModules/composeSharedServiceModules.ts new file mode 100644 index 00000000..c5698106 --- /dev/null +++ b/src/apps/serviceModules/composeSharedServiceModules.ts @@ -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, + databaseFileAccess: DatabaseFileAccess, + storageAccess: StorageAccess +): Pick { + 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 }; +} diff --git a/src/apps/serviceModules/serviceComposition.unit.spec.ts b/src/apps/serviceModules/serviceComposition.unit.spec.ts new file mode 100644 index 00000000..0d81413b --- /dev/null +++ b/src/apps/serviceModules/serviceComposition.unit.spec.ts @@ -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, + instances: {} as Record, + 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) => 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; +} + +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 + ); + + 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, + ]; + const [eventBasePath, eventCore, eventDependencies, ignoreRules, watchEnabled] = composition.dependencies + .StorageEventManagerCLI as [string, unknown, Record, unknown, boolean]; + const storageAccessDependencies = composition.dependencies.ServiceFileAccessCLI as Record; + const databaseAccessDependencies = composition.dependencies.ServiceDatabaseFileAccessCLI as Record< + string, + unknown + >; + const fileHandlerDependencies = composition.dependencies.ServiceFileHandler as Record; + const rebuilderDependencies = composition.dependencies.ServiceRebuilder as Record; + + 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 }).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 + ); + + 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, + ]; + const [eventRoot, eventCore, eventDependencies] = composition.dependencies.StorageEventManagerFSAPI as [ + FileSystemDirectoryHandle, + unknown, + Record, + ]; + const storageAccessDependencies = composition.dependencies.ServiceFileAccessFSAPI as Record; + 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, + }); + }); +}); diff --git a/src/apps/webapp/adapters/FSAPIFileSystemAdapter.ts b/src/apps/webapp/adapters/FSAPIFileSystemAdapter.ts index 432ca0f4..fbf5cfbd 100644 --- a/src/apps/webapp/adapters/FSAPIFileSystemAdapter.ts +++ b/src/apps/webapp/adapters/FSAPIFileSystemAdapter.ts @@ -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 { 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 { diff --git a/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts b/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts index 2859023f..399ca7e7 100644 --- a/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts +++ b/src/apps/webapp/adapters/FSAPIStorageAdapter.unit.spec.ts @@ -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((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" + ); + }); }); diff --git a/src/apps/webapp/adapters/FSAPIVaultAdapter.ts b/src/apps/webapp/adapters/FSAPIVaultAdapter.ts index 9e2d8e27..c31e9f27 100644 --- a/src/apps/webapp/adapters/FSAPIVaultAdapter.ts +++ b/src/apps/webapp/adapters/FSAPIVaultAdapter.ts @@ -36,37 +36,14 @@ export class FSAPIVaultAdapter implements IVaultAdapter { } async create(p: string, data: string, options?: UXDataWriteOptions): Promise { - 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 { + return await this.createFile(p, data); + } + + private async createFile(p: string, data: string | ArrayBuffer): Promise { const parts = p.split("/").filter((part) => part !== ""); const fileName = parts[parts.length - 1]; diff --git a/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts b/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts index 6594b61d..139ebb7d 100644 --- a/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts +++ b/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.ts @@ -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 { - 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) */ diff --git a/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.unit.spec.ts b/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.unit.spec.ts new file mode 100644 index 00000000..881c5b61 --- /dev/null +++ b/src/apps/webapp/managers/FSAPIStorageEventManagerAdapter.unit.spec.ts @@ -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); + }); +}); diff --git a/src/apps/webapp/serviceModules/FSAPIServiceModules.ts b/src/apps/webapp/serviceModules/FSAPIServiceModules.ts index bda8059d..fd70825e 100644 --- a/src/apps/webapp/serviceModules/FSAPIServiceModules.ts +++ b/src/apps/webapp/serviceModules/FSAPIServiceModules.ts @@ -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, diff --git a/src/apps/webpeer/src/WebPeerRuntime.ts b/src/apps/webpeer/src/WebPeerRuntime.ts index fd29947a..2b317d6b 100644 --- a/src/apps/webpeer/src/WebPeerRuntime.ts +++ b/src/apps/webpeer/src/WebPeerRuntime.ts @@ -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); diff --git a/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.ts b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.ts index 7ff2d604..36c021df 100644 --- a/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.ts +++ b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.ts @@ -1,4 +1,5 @@ import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes"; +import { createReviewHarnessHeapSampler } from "./reviewHarnessHeapSampling"; export const REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS = [ { label: "4 KiB", bytes: 4 * 1024 }, @@ -23,15 +24,6 @@ type BenchmarkPerformance = Pick & { type HashAlgorithm = "SHA-256" | "xxHash64"; -function readHeap(clock: BenchmarkPerformance): number | undefined { - try { - const bytes = clock.memory?.usedJSHeapSize; - return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined; - } catch { - return undefined; - } -} - function summary(samples: readonly number[]): string { const sorted = [...samples].sort((a, b) => a - b); const middle = sorted[Math.floor(sorted.length / 2)]; @@ -63,19 +55,13 @@ export async function runReviewHarnessFileContentHashBenchmark( throw new Error("The xxHash64 initialisation time is invalid."); } - const before = readHeap(clock); - let highest = before; - const sampleHeap = () => { - const value = readHeap(clock); - if (value !== undefined) highest = Math.max(highest ?? value, value); - return value; - }; + const heap = createReviewHarnessHeapSampler(clock); const inputs: { readonly label: string; readonly bytes: number; readonly content: Blob }[] = []; for (const { label, bytes } of REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS) { await yieldControl(); inputs.push({ label, bytes, content: operations.createInput(bytes) }); - sampleHeap(); + heap.sample(); } const hash = (algorithm: HashAlgorithm, content: Blob) => @@ -99,7 +85,7 @@ export async function runReviewHarnessFileContentHashBenchmark( const elapsed = clock.now() - started; checkHash(algorithm, inputs[0].label, result); warmupTimes.set(algorithm, elapsed); - sampleHeap(); + heap.sample(); } const observations = [ @@ -124,7 +110,7 @@ export async function runReviewHarnessFileContentHashBenchmark( throw new Error(`The ${algorithm} measurement returned an invalid duration.`); } samples[algorithm].push(elapsed); - sampleHeap(); + heap.sample(); } } for (const algorithm of ["SHA-256", "xxHash64"] as const) { @@ -132,16 +118,7 @@ export async function runReviewHarnessFileContentHashBenchmark( } } - const after = sampleHeap(); - if (highest === undefined) { - observations.push("JavaScript heap: unavailable on this device."); - } else { - const mib = (bytes: number | undefined) => - bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`; - observations.push( - `JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.` - ); - } + observations.push(heap.finish()); observations.push( "Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak." ); diff --git a/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.unit.spec.ts b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.unit.spec.ts index a3ffc39a..1f5e797e 100644 --- a/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.unit.spec.ts +++ b/src/features/ReviewHarness/reviewHarnessFileContentHashBenchmark.unit.spec.ts @@ -74,6 +74,7 @@ describe("Review Harness file content hash measurements", () => { expect(result.status).toBe("passed"); expect(result.detail).toBe("File content hash measurements completed."); + expect(report).toContain("JavaScript heap: unavailable on this device."); expect([...f.inputs().contentBySize.keys()]).toEqual( REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS.map(({ bytes }) => bytes) ); @@ -107,6 +108,49 @@ describe("Review Harness file content hash measurements", () => { expect(report).not.toContain("4096n"); }); + it("tracks the sampled maximum and reports a lower final heap sample", async () => { + const f = fixture(); + let reads = 0; + const clock = { + now: f.now, + get memory() { + const sample = reads++; + const mebibytes = + sample === 0 ? 2 : sample === 1 ? 5 : sample === 31 ? 1 : 3; + return { usedJSHeapSize: mebibytes * 1048576 }; + }, + }; + const result = await runReviewHarnessFileContentHashBenchmark(f.operations, clock, f.yieldControl); + + expect(result.observations).toContain( + "JavaScript heap: before=2.00 MiB; highest sampled=5.00 MiB; after=1.00 MiB." + ); + expect(result.observations.join("\n")).toContain("neither total app RAM nor a true peak"); + expect(reads).toBe(32); + }); + + it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, "throws"] as const)( + "keeps timings usable when the heap API returns %s", + async (value) => { + const f = fixture(); + const result = await runReviewHarnessFileContentHashBenchmark( + f.operations, + { + now: f.now, + get memory() { + if (value === "throws") throw new Error("Heap API unavailable"); + return { usedJSHeapSize: value as number }; + }, + }, + f.yieldControl + ); + + expect(result.status).toBe("passed"); + expect(result.observations).toContain("JavaScript heap: unavailable on this device."); + expect(result.observations.join("\n")).not.toMatch(/NaN|Infinity/u); + } + ); + it("uses the same deterministic byte sequence for short and long inputs", async () => { const shortInput = createFixedFileContentHashInput(4 * 1024); const longInput = createFixedFileContentHashInput(64 * 1024); diff --git a/src/features/ReviewHarness/reviewHarnessHeapSampling.ts b/src/features/ReviewHarness/reviewHarnessHeapSampling.ts new file mode 100644 index 00000000..1b5d286c --- /dev/null +++ b/src/features/ReviewHarness/reviewHarnessHeapSampling.ts @@ -0,0 +1,34 @@ +type HeapPerformance = { + readonly memory?: { readonly usedJSHeapSize: number }; +}; + +function readHeap(clock: HeapPerformance): number | undefined { + try { + const bytes = clock.memory?.usedJSHeapSize; + return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined; + } catch { + return undefined; + } +} + +export function createReviewHarnessHeapSampler(clock: HeapPerformance): { + sample(): void; + finish(): string; +} { + const before = readHeap(clock); + let highest = before; + let after = before; + const sample = () => { + after = readHeap(clock); + if (after !== undefined) highest = Math.max(highest ?? after, after); + }; + const finish = () => { + sample(); + if (highest === undefined) return "JavaScript heap: unavailable on this device."; + const mib = (bytes: number | undefined) => + bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`; + return `JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.`; + }; + + return { sample, finish }; +} diff --git a/src/features/ReviewHarness/reviewHarnessIdBenchmark.ts b/src/features/ReviewHarness/reviewHarnessIdBenchmark.ts index f9212724..6422380c 100644 --- a/src/features/ReviewHarness/reviewHarnessIdBenchmark.ts +++ b/src/features/ReviewHarness/reviewHarnessIdBenchmark.ts @@ -1,4 +1,5 @@ import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes"; +import { createReviewHarnessHeapSampler } from "./reviewHarnessHeapSampling"; export interface IdBenchmarkOperations { deriveKey(): Promise; @@ -15,15 +16,6 @@ const SAMPLES = 3; const BATCH_SIZE = 100; const WARMUP_COUNT = 32; -function readHeap(clock: BenchmarkPerformance): number | undefined { - try { - const bytes = clock.memory?.usedJSHeapSize; - return typeof bytes === "number" && Number.isFinite(bytes) && bytes >= 0 ? bytes : undefined; - } catch { - return undefined; - } -} - function summary(samples: readonly number[]): string { const sorted = [...samples].sort((a, b) => a - b); return `median=${sorted[1].toFixed(2)} ms; range=${sorted[0].toFixed(2)}–${sorted[2].toFixed(2)} ms`; @@ -34,13 +26,7 @@ export async function runReviewHarnessIdBenchmark( clock: BenchmarkPerformance = performance, yieldControl: () => Promise = () => new Promise((resolve) => window.setTimeout(resolve, 0)) ): Promise { - const before = readHeap(clock); - let highest = before; - const sampleHeap = () => { - const value = readHeap(clock); - if (value !== undefined) highest = Math.max(highest ?? value, value); - return value; - }; + const heap = createReviewHarnessHeapSampler(clock); const observations = [ "Fixed synthetic inputs; 3 samples, alternating legacy/independent order; 32 warm-up IDs per sample. Legacy Chunk algorithm: xxhash64.", "Compute timings include input construction and awaited ID generation. Initialisation, warm-up, and pauses between batches are excluded. This does not measure a Rebuild or remote transfer.", @@ -51,7 +37,7 @@ export async function runReviewHarnessIdBenchmark( const started = clock.now(); await operations.deriveKey(); derivationSamples.push(clock.now() - started); - sampleHeap(); + heap.sample(); } observations.push(`ID key derivation at save time: ${summary(derivationSamples)} per derivation.`); @@ -80,7 +66,7 @@ export async function runReviewHarnessIdBenchmark( const started = clock.now(); for (let i = batch; i < batch + BATCH_SIZE; i++) await scenario.run(i, independent); elapsed += clock.now() - started; - sampleHeap(); + heap.sample(); } samples[independent ? 1 : 0].push(elapsed); } @@ -92,16 +78,7 @@ export async function runReviewHarnessIdBenchmark( ); } } - const after = sampleHeap(); - if (highest === undefined) { - observations.push("JavaScript heap: unavailable on this device."); - } else { - const mib = (bytes: number | undefined) => - bytes === undefined ? "unavailable" : `${(bytes / 1048576).toFixed(2)} MiB`; - observations.push( - `JavaScript heap: before=${mib(before)}; highest sampled=${mib(highest)}; after=${mib(after)}.` - ); - } + observations.push(heap.finish()); observations.push( "Heap samples are approximate, may include other Obsidian work, and are affected by garbage collection. They are neither total app RAM nor a true peak." ); diff --git a/src/features/ReviewHarness/reviewHarnessIdBenchmark.unit.spec.ts b/src/features/ReviewHarness/reviewHarnessIdBenchmark.unit.spec.ts index 2e68837a..93bbdedc 100644 --- a/src/features/ReviewHarness/reviewHarnessIdBenchmark.unit.spec.ts +++ b/src/features/ReviewHarness/reviewHarnessIdBenchmark.unit.spec.ts @@ -73,6 +73,7 @@ describe("Review Harness ID measurements", () => { "JavaScript heap: before=2.00 MiB; highest sampled=5.00 MiB; after=1.00 MiB." ); expect(result.observations.join("\n")).toContain("neither total app RAM nor a true peak"); + expect(reads).toBe(245); }); it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, "throws"])( diff --git a/src/modules/features/SettingDialogue/utilFixCouchDBSetting.ts b/src/modules/features/SettingDialogue/utilFixCouchDBSetting.ts index 4a72be8e..246ce622 100644 --- a/src/modules/features/SettingDialogue/utilFixCouchDBSetting.ts +++ b/src/modules/features/SettingDialogue/utilFixCouchDBSetting.ts @@ -13,6 +13,7 @@ import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/repl import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration"; +import { isCouchDBVersionAtLeast, normaliseResponseHeaderKeys } from "@/modules/features/couchDBDiagnosticValues"; export const checkConfig = async ( checkResultDiv: HTMLDivElement | undefined, @@ -89,19 +90,6 @@ export const checkConfig = async ( addResult($msg("obsidianLiveSyncSettingTab.serverVersion", { info: serverBanner })); const versionMatch = serverBanner.match(/CouchDB(\/([0-9.]+))?/); const versionStr = versionMatch ? versionMatch[2] : "0.0.0"; - const versionParts = `${versionStr}.0.0.0`.split("."); - // Compare version string with the target version. - // version must be a string like "3.2.1" or "3.10.2", and must be two or three parts. - function isGreaterThanOrEqual(version: string) { - const targetParts = version.split("."); - for (let i = 0; i < targetParts.length; i++) { - // compare as number if possible (so 3.10 > 3.2, 3.10.1b > 3.10.1a) - const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true }); - if (result > 0) return true; - if (result < 0) return false; - } - return true; - } // Admin check // for database creation and deletion if (!(editingSettings.couchDB_USER in responseConfig.admins)) { @@ -109,7 +97,7 @@ export const checkConfig = async ( } else { addResult($msg("obsidianLiveSyncSettingTab.okAdminPrivileges")); } - if (isGreaterThanOrEqual("3.2.0")) { + if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) { // HTTP user-authorization check if (responseConfig?.chttpd?.require_valid_user != "true") { isSuccessful = false; @@ -148,7 +136,7 @@ export const checkConfig = async ( } else { addResult($msg("obsidianLiveSyncSettingTab.okWwwAuth")); } - if (isGreaterThanOrEqual("3.2.0")) { + if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) { if (responseConfig?.chttpd?.enable_cors != "true") { isSuccessful = false; addResult($msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd")); @@ -240,12 +228,7 @@ export const checkConfig = async ( undefined, customHeaders ); - const responseHeaders = Object.fromEntries( - Object.entries(rr.headers).map((e) => { - e[0] = `${e[0]}`.toLowerCase(); - return e; - }) - ); + const responseHeaders = normaliseResponseHeaderKeys(rr.headers); addResult($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org })); if (responseHeaders["access-control-allow-credentials"] != "true") { addResult($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials")); diff --git a/src/modules/features/SetupWizard/dialogs/utilCheckCouchDB.ts b/src/modules/features/SetupWizard/dialogs/utilCheckCouchDB.ts index ed34d166..507b2da8 100644 --- a/src/modules/features/SetupWizard/dialogs/utilCheckCouchDB.ts +++ b/src/modules/features/SetupWizard/dialogs/utilCheckCouchDB.ts @@ -8,6 +8,7 @@ import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/repl import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc"; import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration"; +import { isCouchDBVersionAtLeast, normaliseResponseHeaderKeys } from "@/modules/features/couchDBDiagnosticValues"; export type ResultMessage = { message: string; classes: string[] }; export type ResultErrorMessage = { message: string; result: "error"; classes: string[] }; @@ -26,24 +27,6 @@ export type ConfigCheckResult = | ResultError | ResultMessage | ResultErrorMessage; -/** - * Compares two version strings to determine if the baseVersion is greater than or equal to the version. - * @param baseVersion a.b.c format - * @param version a.b.c format - * @returns true if baseVersion is greater than or equal to version, false otherwise - */ -function isGreaterThanOrEqual(baseVersion: string, version: string) { - const versionParts = `${baseVersion}.0.0.0`.split("."); - const targetParts = version.split("."); - for (let i = 0; i < targetParts.length; i++) { - // compare as number if possible (so 3.10 > 3.2, 3.10.1b > 3.10.1a) - const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true }); - if (result > 0) return true; - if (result < 0) return false; - } - return true; -} - /** * Updates the remote CouchDB setting with the given key and value. * @param setting Connection settings @@ -153,7 +136,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) => } else { addSuccess($msg("obsidianLiveSyncSettingTab.okAdminPrivileges")); } - if (isGreaterThanOrEqual(versionStr, "3.2.0")) { + if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) { // HTTP user-authorization check if (responseConfig?.chttpd?.require_valid_user != "true") { addError( @@ -189,7 +172,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) => } else { addSuccess($msg("obsidianLiveSyncSettingTab.okWwwAuth")); } - if (isGreaterThanOrEqual(versionStr, "3.2.0")) { + if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) { if (responseConfig?.chttpd?.enable_cors != "true") { addError( $msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"), @@ -287,12 +270,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) => undefined, customHeaders ); - const responseHeaders = Object.fromEntries( - Object.entries(rr.headers).map((e) => { - e[0] = `${e[0]}`.toLowerCase(); - return e; - }) - ); + const responseHeaders = normaliseResponseHeaderKeys(rr.headers); addMessage($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org })); if (responseHeaders["access-control-allow-credentials"] != "true") { addErrorMessage($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials")); diff --git a/src/modules/features/couchDBDiagnosticValues.ts b/src/modules/features/couchDBDiagnosticValues.ts new file mode 100644 index 00000000..41208ab5 --- /dev/null +++ b/src/modules/features/couchDBDiagnosticValues.ts @@ -0,0 +1,19 @@ +export function isCouchDBVersionAtLeast(baseVersion: string, targetVersion: string) { + const versionParts = `${baseVersion}.0.0.0`.split("."); + const targetParts = targetVersion.split("."); + for (let i = 0; i < targetParts.length; i++) { + const result = versionParts[i].localeCompare(targetParts[i], undefined, { numeric: true }); + if (result > 0) return true; + if (result < 0) return false; + } + return true; +} + +export function normaliseResponseHeaderKeys(headers: Record) { + return Object.fromEntries( + Object.entries(headers).map((entry) => { + entry[0] = `${entry[0]}`.toLowerCase(); + return entry; + }) + ); +} diff --git a/src/modules/features/couchDBDiagnosticValues.unit.spec.ts b/src/modules/features/couchDBDiagnosticValues.unit.spec.ts new file mode 100644 index 00000000..485a7ba7 --- /dev/null +++ b/src/modules/features/couchDBDiagnosticValues.unit.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { isCouchDBVersionAtLeast, normaliseResponseHeaderKeys } from "./couchDBDiagnosticValues"; + +describe("CouchDB diagnostic values", () => { + describe("version comparison", () => { + it.each([ + ["3.10.2", "3.2.0", true], + ["3.2.0", "3.10.0", false], + ["3.2", "3.2.0", true], + ["3.10.1b", "3.10.1a", true], + ["3.10.1a", "3.10.1b", false], + ["3.x", "3.2", true], + ])("compares %s with %s", (baseVersion, targetVersion, expected) => { + expect(isCouchDBVersionAtLeast(baseVersion, targetVersion)).toBe(expected); + }); + }); + + describe("response header keys", () => { + it("lower-cases keys while retaining last collision values and entry order", () => { + const headers = { Server: "first", server: "last", Zed: "z", Alpha: "a" }; + + const normalised = normaliseResponseHeaderKeys(headers); + + expect(normalised).toEqual({ server: "last", zed: "z", alpha: "a" }); + expect(Object.keys(normalised)).toEqual(["server", "zed", "alpha"]); + expect(headers).toEqual({ Server: "first", server: "last", Zed: "z", Alpha: "a" }); + }); + }); +});