mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-03 06:48:18 +00:00
Share application adapter and diagnostic helpers
This commit is contained in:
1 parent
4027417bf5
commit
b9dcb0d4c7
26 files changed
+937
-305
No files matched your search
@@ -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.
|
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:
|
Legacy Modules remain grouped by directory:
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { NodeVaultAdapter } from "./NodeVaultAdapter";
|
|||||||
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
|
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
|
||||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||||
|
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete file system adapter implementation for Node.js
|
* Complete file system adapter implementation for Node.js
|
||||||
@@ -87,27 +88,12 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
|||||||
return await this.refreshResolvedFile(resolvedPath);
|
return await this.refreshResolvedFile(resolvedPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const exact = await this.getAbstractFileByPath(pathStr);
|
return await findCaseInsensitiveFile(
|
||||||
if (exact) {
|
pathStr,
|
||||||
return exact;
|
() => this.getAbstractFileByPath(pathStr),
|
||||||
}
|
this.fileCache,
|
||||||
|
() => this.scanDirectory()
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFiles(): Promise<NodeFile[]> {
|
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", () => {
|
describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||||
const tempDirs: string[] = [];
|
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 { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
||||||
import type {
|
import type {
|
||||||
IStorageEventTypeGuardAdapter,
|
|
||||||
IStorageEventPersistenceAdapter,
|
IStorageEventPersistenceAdapter,
|
||||||
IStorageEventWatchAdapter,
|
IStorageEventWatchAdapter,
|
||||||
IStorageEventStatusAdapter,
|
IStorageEventStatusAdapter,
|
||||||
@@ -15,30 +14,7 @@ import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
|||||||
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
|
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
|
||||||
import { fsPromises as fs, getNodeFileCreationTime, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
|
import { fsPromises as fs, getNodeFileCreationTime, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||||
|
import { NodeTypeGuardAdapter } from "@/apps/cli/adapters/NodeTypeGuardAdapter";
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CLI-specific persistence adapter (file-based snapshot)
|
* CLI-specific persistence adapter (file-based snapshot)
|
||||||
@@ -211,7 +187,7 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
|||||||
* Composite adapter for CLI StorageEventManager
|
* Composite adapter for CLI StorageEventManager
|
||||||
*/
|
*/
|
||||||
export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapter<NodeFile, NodeFolder> {
|
export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapter<NodeFile, NodeFolder> {
|
||||||
readonly typeGuard: CLITypeGuardAdapter;
|
readonly typeGuard: NodeTypeGuardAdapter;
|
||||||
readonly persistence: CLIPersistenceAdapter;
|
readonly persistence: CLIPersistenceAdapter;
|
||||||
readonly watch: CLIWatchAdapter;
|
readonly watch: CLIWatchAdapter;
|
||||||
readonly status: CLIStatusAdapter;
|
readonly status: CLIStatusAdapter;
|
||||||
@@ -223,7 +199,7 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
|
|||||||
watchEnabled: boolean = false,
|
watchEnabled: boolean = false,
|
||||||
reportDiagnostic: CliDiagnosticReporter = () => undefined
|
reportDiagnostic: CliDiagnosticReporter = () => undefined
|
||||||
) {
|
) {
|
||||||
this.typeGuard = new CLITypeGuardAdapter();
|
this.typeGuard = new NodeTypeGuardAdapter();
|
||||||
this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic);
|
this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic);
|
||||||
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic);
|
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic);
|
||||||
this.status = new CLIStatusAdapter();
|
this.status = new CLIStatusAdapter();
|
||||||
|
|||||||
@@ -201,3 +201,27 @@ describe("CLIStorageEventManagerAdapter", () => {
|
|||||||
processExitSpy.mockRestore();
|
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 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 { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
|
||||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
@@ -10,7 +8,7 @@ import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
|||||||
import { StorageEventManagerCLI } from "@/apps/cli/managers/StorageEventManagerCLI";
|
import { StorageEventManagerCLI } from "@/apps/cli/managers/StorageEventManagerCLI";
|
||||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||||
import type { IgnoreRules } from "./IgnoreRules";
|
import type { IgnoreRules } from "./IgnoreRules";
|
||||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize service modules for CLI version
|
* Initialize service modules for CLI version
|
||||||
@@ -82,40 +80,7 @@ export function initialiseServiceModulesCLI(
|
|||||||
vault: services.vault,
|
vault: services.vault,
|
||||||
});
|
});
|
||||||
|
|
||||||
// File handler (platform-independent)
|
const { fileHandler, rebuilder } = composeSharedServiceModules(services, databaseFileAccess, storageAccess);
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rebuilder,
|
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");
|
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
|
// 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 type { FSAPIFile, FSAPIFolder, FSAPIStat } from "./FSAPITypes";
|
||||||
import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2";
|
import { shareRunningResult } from "octagonal-wheels/concurrency/lock_v2";
|
||||||
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
||||||
|
import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete file system adapter implementation for FileSystem API
|
* 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> {
|
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<FSAPIFile | null> {
|
||||||
const pathStr = this.normalisePath(p);
|
const pathStr = this.normalisePath(p);
|
||||||
const exact = await this.getAbstractFileByPath(pathStr);
|
return await findCaseInsensitiveFile(
|
||||||
if (exact) {
|
pathStr,
|
||||||
return exact;
|
() => this.getAbstractFileByPath(pathStr),
|
||||||
}
|
this.fileCache,
|
||||||
// TODO: Refactor: Very, Very heavy.
|
() => this.scanDirectory()
|
||||||
|
);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFiles(): Promise<FSAPIFile[]> {
|
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", () => {
|
describe("FSAPIVaultAdapter.rename", () => {
|
||||||
it("moves the file through a temporary copy while preserving content", async () => {
|
it("moves the file through a temporary copy while preserving content", async () => {
|
||||||
const memoryRoot = new MemoryDirectoryHandle("root");
|
const memoryRoot = new MemoryDirectoryHandle("root");
|
||||||
@@ -142,6 +219,20 @@ describe("FSAPIVaultAdapter.rename", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("FSAPIFileSystemAdapter path case", () => {
|
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 () => {
|
it("returns the stored path case from a case-insensitive file system", async () => {
|
||||||
const memoryRoot = new MemoryDirectoryHandle("root", true);
|
const memoryRoot = new MemoryDirectoryHandle("root", true);
|
||||||
const root = memoryRoot as unknown as FileSystemDirectoryHandle;
|
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 () => {
|
it("reports scan failures through the injected Webapp log", async () => {
|
||||||
const root = {
|
const root = {
|
||||||
name: "root",
|
name: "root",
|
||||||
@@ -173,4 +306,25 @@ describe("FSAPIFileSystemAdapter path case", () => {
|
|||||||
"fsapi-scan"
|
"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"
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -36,37 +36,14 @@ export class FSAPIVaultAdapter implements IVaultAdapter<FSAPIFile> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
return await this.createFile(p, data);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<FSAPIFile> {
|
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 parts = p.split("/").filter((part) => part !== "");
|
||||||
const fileName = parts[parts.length - 1];
|
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 { FileEventItem } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
import type { IStorageEventManagerAdapter } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
|
||||||
import type {
|
import type {
|
||||||
IStorageEventTypeGuardAdapter,
|
|
||||||
IStorageEventPersistenceAdapter,
|
IStorageEventPersistenceAdapter,
|
||||||
IStorageEventWatchAdapter,
|
IStorageEventWatchAdapter,
|
||||||
IStorageEventStatusAdapter,
|
IStorageEventStatusAdapter,
|
||||||
@@ -14,6 +13,7 @@ import type { FSAPIFile, FSAPIFolder } from "@/apps/webapp/adapters/FSAPITypes";
|
|||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
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 { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
import type { WebAppLog } from "@/apps/webapp/WebAppLog";
|
||||||
|
import { FSAPITypeGuardAdapter } from "@/apps/webapp/adapters/FSAPITypeGuardAdapter";
|
||||||
|
|
||||||
type FileSystemObserverRecord = {
|
type FileSystemObserverRecord = {
|
||||||
changedHandle?: FileSystemFileHandle | FileSystemDirectoryHandle;
|
changedHandle?: FileSystemFileHandle | FileSystemDirectoryHandle;
|
||||||
@@ -34,32 +34,6 @@ type GlobalWithFileSystemObserver = typeof compatGlobal & {
|
|||||||
FileSystemObserver?: FileSystemObserverConstructor;
|
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)
|
* 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 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 { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
|
||||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||||
@@ -9,8 +8,7 @@ import { ServiceFileAccessFSAPI } from "./ServiceFileAccessImpl";
|
|||||||
import { ServiceDatabaseFileAccessFSAPI } from "./DatabaseFileAccess";
|
import { ServiceDatabaseFileAccessFSAPI } from "./DatabaseFileAccess";
|
||||||
import { StorageEventManagerFSAPI } from "@/apps/webapp/managers/StorageEventManagerFSAPI";
|
import { StorageEventManagerFSAPI } from "@/apps/webapp/managers/StorageEventManagerFSAPI";
|
||||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||||
import { ServiceFileHandler } from "@/serviceModules/FileHandler";
|
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
|
||||||
import { createFileReflectionProvenance } from "@/serviceModules/FileReflectionProvenance";
|
|
||||||
|
|
||||||
export interface FSAPIServiceModules extends ServiceModules {
|
export interface FSAPIServiceModules extends ServiceModules {
|
||||||
vaultAccess: FileAccessFSAPI;
|
vaultAccess: FileAccessFSAPI;
|
||||||
@@ -73,40 +71,7 @@ export function initialiseServiceModulesFSAPI(
|
|||||||
vault: services.vault,
|
vault: services.vault,
|
||||||
});
|
});
|
||||||
|
|
||||||
// File handler (platform-independent)
|
const { fileHandler, rebuilder } = composeSharedServiceModules(services, databaseFileAccess, storageAccess);
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rebuilder,
|
rebuilder,
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export class WebPeerRuntime {
|
|||||||
|
|
||||||
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
|
private showPeerMenu(peer: PeerStatus, event: MouseEvent): void {
|
||||||
this.menu?.hide();
|
this.menu?.hide();
|
||||||
this.menu = new Menu()
|
this.menu = new Menu(this.services.browserUi)
|
||||||
.addItem((item) =>
|
.addItem((item) =>
|
||||||
item.setTitle("📥 Only fetch").onClick(async () => {
|
item.setTitle("📥 Only fetch").onClick(async () => {
|
||||||
await this.p2p.targetedTransfer.pullFromPeer(peer.peerId);
|
await this.p2p.targetedTransfer.pullFromPeer(peer.peerId);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
|
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
|
||||||
|
import { createReviewHarnessHeapSampler } from "./reviewHarnessHeapSampling";
|
||||||
|
|
||||||
export const REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS = [
|
export const REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS = [
|
||||||
{ label: "4 KiB", bytes: 4 * 1024 },
|
{ label: "4 KiB", bytes: 4 * 1024 },
|
||||||
@@ -23,15 +24,6 @@ type BenchmarkPerformance = Pick<Performance, "now"> & {
|
|||||||
|
|
||||||
type HashAlgorithm = "SHA-256" | "xxHash64";
|
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 {
|
function summary(samples: readonly number[]): string {
|
||||||
const sorted = [...samples].sort((a, b) => a - b);
|
const sorted = [...samples].sort((a, b) => a - b);
|
||||||
const middle = sorted[Math.floor(sorted.length / 2)];
|
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.");
|
throw new Error("The xxHash64 initialisation time is invalid.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const before = readHeap(clock);
|
const heap = createReviewHarnessHeapSampler(clock);
|
||||||
let highest = before;
|
|
||||||
const sampleHeap = () => {
|
|
||||||
const value = readHeap(clock);
|
|
||||||
if (value !== undefined) highest = Math.max(highest ?? value, value);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputs: { readonly label: string; readonly bytes: number; readonly content: Blob }[] = [];
|
const inputs: { readonly label: string; readonly bytes: number; readonly content: Blob }[] = [];
|
||||||
for (const { label, bytes } of REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS) {
|
for (const { label, bytes } of REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS) {
|
||||||
await yieldControl();
|
await yieldControl();
|
||||||
inputs.push({ label, bytes, content: operations.createInput(bytes) });
|
inputs.push({ label, bytes, content: operations.createInput(bytes) });
|
||||||
sampleHeap();
|
heap.sample();
|
||||||
}
|
}
|
||||||
|
|
||||||
const hash = (algorithm: HashAlgorithm, content: Blob) =>
|
const hash = (algorithm: HashAlgorithm, content: Blob) =>
|
||||||
@@ -99,7 +85,7 @@ export async function runReviewHarnessFileContentHashBenchmark(
|
|||||||
const elapsed = clock.now() - started;
|
const elapsed = clock.now() - started;
|
||||||
checkHash(algorithm, inputs[0].label, result);
|
checkHash(algorithm, inputs[0].label, result);
|
||||||
warmupTimes.set(algorithm, elapsed);
|
warmupTimes.set(algorithm, elapsed);
|
||||||
sampleHeap();
|
heap.sample();
|
||||||
}
|
}
|
||||||
|
|
||||||
const observations = [
|
const observations = [
|
||||||
@@ -124,7 +110,7 @@ export async function runReviewHarnessFileContentHashBenchmark(
|
|||||||
throw new Error(`The ${algorithm} measurement returned an invalid duration.`);
|
throw new Error(`The ${algorithm} measurement returned an invalid duration.`);
|
||||||
}
|
}
|
||||||
samples[algorithm].push(elapsed);
|
samples[algorithm].push(elapsed);
|
||||||
sampleHeap();
|
heap.sample();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const algorithm of ["SHA-256", "xxHash64"] as const) {
|
for (const algorithm of ["SHA-256", "xxHash64"] as const) {
|
||||||
@@ -132,16 +118,7 @@ export async function runReviewHarnessFileContentHashBenchmark(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const after = sampleHeap();
|
observations.push(heap.finish());
|
||||||
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(
|
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."
|
"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."
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ describe("Review Harness file content hash measurements", () => {
|
|||||||
|
|
||||||
expect(result.status).toBe("passed");
|
expect(result.status).toBe("passed");
|
||||||
expect(result.detail).toBe("File content hash measurements completed.");
|
expect(result.detail).toBe("File content hash measurements completed.");
|
||||||
|
expect(report).toContain("JavaScript heap: unavailable on this device.");
|
||||||
expect([...f.inputs().contentBySize.keys()]).toEqual(
|
expect([...f.inputs().contentBySize.keys()]).toEqual(
|
||||||
REVIEW_HARNESS_FILE_CONTENT_HASH_INPUTS.map(({ bytes }) => bytes)
|
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");
|
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 () => {
|
it("uses the same deterministic byte sequence for short and long inputs", async () => {
|
||||||
const shortInput = createFixedFileContentHashInput(4 * 1024);
|
const shortInput = createFixedFileContentHashInput(4 * 1024);
|
||||||
const longInput = createFixedFileContentHashInput(64 * 1024);
|
const longInput = createFixedFileContentHashInput(64 * 1024);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
|
import type { ReviewHarnessScenarioResult } from "./reviewHarnessTypes";
|
||||||
|
import { createReviewHarnessHeapSampler } from "./reviewHarnessHeapSampling";
|
||||||
|
|
||||||
export interface IdBenchmarkOperations {
|
export interface IdBenchmarkOperations {
|
||||||
deriveKey(): Promise<unknown>;
|
deriveKey(): Promise<unknown>;
|
||||||
@@ -15,15 +16,6 @@ const SAMPLES = 3;
|
|||||||
const BATCH_SIZE = 100;
|
const BATCH_SIZE = 100;
|
||||||
const WARMUP_COUNT = 32;
|
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 {
|
function summary(samples: readonly number[]): string {
|
||||||
const sorted = [...samples].sort((a, b) => a - b);
|
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`;
|
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,
|
clock: BenchmarkPerformance = performance,
|
||||||
yieldControl: () => Promise<void> = () => new Promise((resolve) => window.setTimeout(resolve, 0))
|
yieldControl: () => Promise<void> = () => new Promise((resolve) => window.setTimeout(resolve, 0))
|
||||||
): Promise<ReviewHarnessScenarioResult> {
|
): Promise<ReviewHarnessScenarioResult> {
|
||||||
const before = readHeap(clock);
|
const heap = createReviewHarnessHeapSampler(clock);
|
||||||
let highest = before;
|
|
||||||
const sampleHeap = () => {
|
|
||||||
const value = readHeap(clock);
|
|
||||||
if (value !== undefined) highest = Math.max(highest ?? value, value);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
const observations = [
|
const observations = [
|
||||||
"Fixed synthetic inputs; 3 samples, alternating legacy/independent order; 32 warm-up IDs per sample. Legacy Chunk algorithm: xxhash64.",
|
"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.",
|
"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();
|
const started = clock.now();
|
||||||
await operations.deriveKey();
|
await operations.deriveKey();
|
||||||
derivationSamples.push(clock.now() - started);
|
derivationSamples.push(clock.now() - started);
|
||||||
sampleHeap();
|
heap.sample();
|
||||||
}
|
}
|
||||||
observations.push(`ID key derivation at save time: ${summary(derivationSamples)} per derivation.`);
|
observations.push(`ID key derivation at save time: ${summary(derivationSamples)} per derivation.`);
|
||||||
|
|
||||||
@@ -80,7 +66,7 @@ export async function runReviewHarnessIdBenchmark(
|
|||||||
const started = clock.now();
|
const started = clock.now();
|
||||||
for (let i = batch; i < batch + BATCH_SIZE; i++) await scenario.run(i, independent);
|
for (let i = batch; i < batch + BATCH_SIZE; i++) await scenario.run(i, independent);
|
||||||
elapsed += clock.now() - started;
|
elapsed += clock.now() - started;
|
||||||
sampleHeap();
|
heap.sample();
|
||||||
}
|
}
|
||||||
samples[independent ? 1 : 0].push(elapsed);
|
samples[independent ? 1 : 0].push(elapsed);
|
||||||
}
|
}
|
||||||
@@ -92,16 +78,7 @@ export async function runReviewHarnessIdBenchmark(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const after = sampleHeap();
|
observations.push(heap.finish());
|
||||||
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(
|
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."
|
"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."
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ describe("Review Harness ID measurements", () => {
|
|||||||
"JavaScript heap: before=2.00 MiB; highest sampled=5.00 MiB; after=1.00 MiB."
|
"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(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"])(
|
it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, "throws"])(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/repl
|
|||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||||
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
||||||
|
import { isCouchDBVersionAtLeast, normaliseResponseHeaderKeys } from "@/modules/features/couchDBDiagnosticValues";
|
||||||
|
|
||||||
export const checkConfig = async (
|
export const checkConfig = async (
|
||||||
checkResultDiv: HTMLDivElement | undefined,
|
checkResultDiv: HTMLDivElement | undefined,
|
||||||
@@ -89,19 +90,6 @@ export const checkConfig = async (
|
|||||||
addResult($msg("obsidianLiveSyncSettingTab.serverVersion", { info: serverBanner }));
|
addResult($msg("obsidianLiveSyncSettingTab.serverVersion", { info: serverBanner }));
|
||||||
const versionMatch = serverBanner.match(/CouchDB(\/([0-9.]+))?/);
|
const versionMatch = serverBanner.match(/CouchDB(\/([0-9.]+))?/);
|
||||||
const versionStr = versionMatch ? versionMatch[2] : "0.0.0";
|
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
|
// Admin check
|
||||||
// for database creation and deletion
|
// for database creation and deletion
|
||||||
if (!(editingSettings.couchDB_USER in responseConfig.admins)) {
|
if (!(editingSettings.couchDB_USER in responseConfig.admins)) {
|
||||||
@@ -109,7 +97,7 @@ export const checkConfig = async (
|
|||||||
} else {
|
} else {
|
||||||
addResult($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
addResult($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
||||||
}
|
}
|
||||||
if (isGreaterThanOrEqual("3.2.0")) {
|
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||||
// HTTP user-authorization check
|
// HTTP user-authorization check
|
||||||
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
||||||
isSuccessful = false;
|
isSuccessful = false;
|
||||||
@@ -148,7 +136,7 @@ export const checkConfig = async (
|
|||||||
} else {
|
} else {
|
||||||
addResult($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
addResult($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
||||||
}
|
}
|
||||||
if (isGreaterThanOrEqual("3.2.0")) {
|
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||||
if (responseConfig?.chttpd?.enable_cors != "true") {
|
if (responseConfig?.chttpd?.enable_cors != "true") {
|
||||||
isSuccessful = false;
|
isSuccessful = false;
|
||||||
addResult($msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"));
|
addResult($msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"));
|
||||||
@@ -240,12 +228,7 @@ export const checkConfig = async (
|
|||||||
undefined,
|
undefined,
|
||||||
customHeaders
|
customHeaders
|
||||||
);
|
);
|
||||||
const responseHeaders = Object.fromEntries(
|
const responseHeaders = normaliseResponseHeaderKeys(rr.headers);
|
||||||
Object.entries(rr.headers).map((e) => {
|
|
||||||
e[0] = `${e[0]}`.toLowerCase();
|
|
||||||
return e;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
addResult($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
addResult($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
||||||
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
||||||
addResult($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
addResult($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { generateCredentialObject } from "@vrtmrz/livesync-commonlib/compat/repl
|
|||||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||||
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
import { isUnauthorizedError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
||||||
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
import { normaliseCouchDBConfiguration } from "@/common/couchdbConfiguration";
|
||||||
|
import { isCouchDBVersionAtLeast, normaliseResponseHeaderKeys } from "@/modules/features/couchDBDiagnosticValues";
|
||||||
|
|
||||||
export type ResultMessage = { message: string; classes: string[] };
|
export type ResultMessage = { message: string; classes: string[] };
|
||||||
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
|
export type ResultErrorMessage = { message: string; result: "error"; classes: string[] };
|
||||||
@@ -26,24 +27,6 @@ export type ConfigCheckResult<T = unknown, U = unknown> =
|
|||||||
| ResultError<U>
|
| ResultError<U>
|
||||||
| ResultMessage
|
| ResultMessage
|
||||||
| ResultErrorMessage;
|
| 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.
|
* Updates the remote CouchDB setting with the given key and value.
|
||||||
* @param setting Connection settings
|
* @param setting Connection settings
|
||||||
@@ -153,7 +136,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
|||||||
} else {
|
} else {
|
||||||
addSuccess($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
addSuccess($msg("obsidianLiveSyncSettingTab.okAdminPrivileges"));
|
||||||
}
|
}
|
||||||
if (isGreaterThanOrEqual(versionStr, "3.2.0")) {
|
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||||
// HTTP user-authorization check
|
// HTTP user-authorization check
|
||||||
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
if (responseConfig?.chttpd?.require_valid_user != "true") {
|
||||||
addError(
|
addError(
|
||||||
@@ -189,7 +172,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
|||||||
} else {
|
} else {
|
||||||
addSuccess($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
addSuccess($msg("obsidianLiveSyncSettingTab.okWwwAuth"));
|
||||||
}
|
}
|
||||||
if (isGreaterThanOrEqual(versionStr, "3.2.0")) {
|
if (isCouchDBVersionAtLeast(versionStr, "3.2.0")) {
|
||||||
if (responseConfig?.chttpd?.enable_cors != "true") {
|
if (responseConfig?.chttpd?.enable_cors != "true") {
|
||||||
addError(
|
addError(
|
||||||
$msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"),
|
$msg("obsidianLiveSyncSettingTab.errEnableCorsChttpd"),
|
||||||
@@ -287,12 +270,7 @@ export const checkConfig = async (editingSettings: ObsidianLiveSyncSettings) =>
|
|||||||
undefined,
|
undefined,
|
||||||
customHeaders
|
customHeaders
|
||||||
);
|
);
|
||||||
const responseHeaders = Object.fromEntries(
|
const responseHeaders = normaliseResponseHeaderKeys(rr.headers);
|
||||||
Object.entries(rr.headers).map((e) => {
|
|
||||||
e[0] = `${e[0]}`.toLowerCase();
|
|
||||||
return e;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
addMessage($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
addMessage($msg("obsidianLiveSyncSettingTab.msgOriginCheck", { org }));
|
||||||
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
if (responseHeaders["access-control-allow-credentials"] != "true") {
|
||||||
addErrorMessage($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
addErrorMessage($msg("obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials"));
|
||||||
|
|||||||
@@ -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<T>(headers: Record<string, T>) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(headers).map((entry) => {
|
||||||
|
entry[0] = `${entry[0]}`.toLowerCase();
|
||||||
|
return entry;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user