Maintain watched file names for CLI collision checks

Share the CLI watcher with a reconciled name inventory, observe local
mutations, and retain newer notifications across overlapping reads.
Adopt the shared index for Obsidian and preserve fresh bulk operations.
This commit is contained in:
vorotamoroz committed 2026-11-12 14:04:17 +00:00
1 parent 78ebc2d298
commit 2a65fe0117
35 files changed
+5440 -28

No files matched your search

+14 -2
View File
@@ -4,6 +4,7 @@ import { NodePathAdapter } from "./NodePathAdapter";
import { NodeTypeGuardAdapter } from "./NodeTypeGuardAdapter";
import { NodeConversionAdapter } from "./NodeConversionAdapter";
import { AmbiguousNormalisedPathError, UnicodeResolvingNodeStorageAdapter } from "./UnicodeResolvingNodeStorageAdapter";
import { ObservedNodeStorageAdapter } from "@/apps/cli/storage/ObservedNodeStorageAdapter";
import { NodeVaultAdapter } from "./NodeVaultAdapter";
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
import { path } from "@vrtmrz/livesync-commonlib/node";
@@ -15,6 +16,7 @@ import {
parseFilePath,
type StoragePath,
type StoragePathResolver,
type FileNameLookup,
} from "@vrtmrz/livesync-commonlib/storage";
/**
@@ -26,7 +28,11 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
readonly conversion: NodeConversionAdapter;
readonly storage: UnicodeResolvingNodeStorageAdapter;
readonly vault: NodeVaultAdapter;
/** Optional watched name capability; single-run CLI commands retain physical listing. */
fileNameLookup?: FileNameLookup;
// Previously accessed objects are not a complete name inventory. Full getFiles
// enumeration still reads current metadata rather than treating this cache as a snapshot.
private fileCache = new Map<string, NodeFile>();
private readonly isCaseInsensitiveSetting: () => boolean;
@@ -34,13 +40,19 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
private basePath: string,
private reportDiagnostic: CliDiagnosticReporter = () => undefined,
getPathNormalizationVersion: () => number = () => 0,
isCaseInsensitive: () => boolean = () => false
isCaseInsensitive: () => boolean = () => false,
onMutation?: (paths: readonly string[]) => () => void
) {
this.path = new NodePathAdapter();
this.typeGuard = new NodeTypeGuardAdapter();
this.conversion = new NodeConversionAdapter();
this.isCaseInsensitiveSetting = isCaseInsensitive;
this.storage = new UnicodeResolvingNodeStorageAdapter(basePath, getPathNormalizationVersion, isCaseInsensitive);
this.storage = new ObservedNodeStorageAdapter(
basePath,
getPathNormalizationVersion,
isCaseInsensitive,
onMutation
);
this.vault = new NodeVaultAdapter(this.storage);
}
@@ -6,6 +6,8 @@ import { promisify } from "node:util";
import { setTimeout as delay } from "node:timers/promises";
import { describe, expect, it } from "vitest";
import type { IStorageEventWatchHandlers } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
import { UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import { CLIFileNameSource } from "@/apps/cli/storage/CLIFileNameSource";
import { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
import { NodeTypeGuardAdapter } from "@/apps/cli/adapters/NodeTypeGuardAdapter";
@@ -35,6 +37,18 @@ function makeHandlers(events: WatchEvent[], onRename: () => void = () => undefin
};
}
function makeIndexedAdapter(root: string, ignoreRules?: IgnoreRules) {
const source = new CLIFileNameSource(
new UnicodeResolvingNodeStorageAdapter(root),
() => ({ pathNormalizationVersion: 0, handleFilenameCaseSensitive: true }),
{ basePath: root, ignoreRules }
);
return {
source,
adapter: new CLIStorageEventManagerAdapter(root, ignoreRules, true, undefined, undefined, source),
};
}
async function waitFor(predicate: () => boolean, timeoutMs = 5_000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
@@ -69,13 +83,14 @@ describe("CLI filesystem watcher with a real filesystem", () => {
it("reports all 400 files when another process creates directories before dot-temp renames", async () => {
const root = await mkdtemp(join(tmpdir(), "livesync-issue1328-burst-"));
const events: WatchEvent[] = [];
const adapter = new CLIStorageEventManagerAdapter(root, undefined, true);
const { source, adapter } = makeIndexedAdapter(root);
const expectedPaths = Array.from({ length: 40 }, (_, directoryIndex) =>
Array.from({ length: 10 }, (_, fileIndex) => `book-${directoryIndex}/image-${fileIndex}.png`)
).flat();
try {
await writeFile(join(root, "present-before-watch.md"), "initial file");
await source.prepare();
await adapter.watch.beginWatch(makeHandlers(events));
await run(process.execPath, ["-e", directoryFirstWriter, root], { timeout: 15_000 });
@@ -122,9 +137,10 @@ describe("CLI filesystem watcher with a real filesystem", () => {
await mkdir(join(root, "private"));
await writeFile(join(root, "present-before-watch.md"), "initial file");
await writeFile(join(root, ".hidden-before-watch.md"), "initial hidden file");
const adapter = new CLIStorageEventManagerAdapter(root, ignoreRules, true);
const { source, adapter } = makeIndexedAdapter(root, ignoreRules);
try {
await source.prepare();
await adapter.watch.beginWatch(makeHandlers(events));
await writeFile(join(root, "visible-after-watch.md"), "visible");
await writeFile(join(root, ".hidden-after-watch.md"), "hidden");
@@ -149,11 +165,12 @@ describe("CLI filesystem watcher with a real filesystem", () => {
it("reports changes, deletions, rename paths, and stops after close", async () => {
const root = await mkdtemp(join(tmpdir(), "livesync-real-watch-lifecycle-"));
const events: WatchEvent[] = [];
const adapter = new CLIStorageEventManagerAdapter(root, undefined, true);
const { source, adapter } = makeIndexedAdapter(root);
let closed = false;
let renameCalls = 0;
try {
await source.prepare();
await adapter.watch.beginWatch(makeHandlers(events, () => renameCalls++));
const originalPath = join(root, "original.md");
@@ -15,6 +15,7 @@ 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";
import { NodeTypeGuardAdapter } from "@/apps/cli/adapters/NodeTypeGuardAdapter";
import type { CLIFileNameSource } from "@/apps/cli/storage/CLIFileNameSource";
import { writeFileAtomically } from "@/apps/cli/runtimeState";
/**
@@ -92,7 +93,8 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
private basePath: string,
private ignoreRules?: IgnoreRules,
private watchEnabled: boolean = false,
private reportDiagnostic: CliDiagnosticReporter = () => undefined
private reportDiagnostic: CliDiagnosticReporter = () => undefined,
private nameSource?: CLIFileNameSource
) {}
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
@@ -118,6 +120,10 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
if (!this.watchEnabled) return;
if (this.nameSource) {
await this.nameSource.beginWatch(handlers);
return;
}
const baseIgnored: Array<RegExp | string | ((p: string) => boolean)> = [
/(^|[/\\])\./,
/(^|[/\\])[^/\\]*-livesync-v2([/\\]|$)/,
@@ -178,6 +184,9 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
}
close(): Promise<void> {
if (this.nameSource) {
return this.nameSource.close();
}
if (this._watcher) {
return this._watcher.close();
}
@@ -200,11 +209,12 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
ignoreRules?: IgnoreRules,
watchEnabled: boolean = false,
reportDiagnostic: CliDiagnosticReporter = () => undefined,
snapshotPath?: () => string
snapshotPath?: () => string,
nameSource?: CLIFileNameSource
) {
this.typeGuard = new NodeTypeGuardAdapter();
this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic, snapshotPath);
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic);
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic, nameSource);
this.status = new CLIStatusAdapter();
this.converter = new CLIConverterAdapter();
}
@@ -1,5 +1,6 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { IStorageEventWatchHandlers } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
import type { CLIFileNameSource } from "@/apps/cli/storage/CLIFileNameSource";
import type { NodeFile } from "@/apps/cli/adapters/NodeTypes";
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import { runtimeScopeDirectory } from "../runtimeState";
@@ -86,6 +87,24 @@ describe("CLIStorageEventManagerAdapter", () => {
expect(chokidar.watch).toHaveBeenCalledWith("/base", expect.objectContaining({ ignoreInitial: true }));
});
it("borrows the prepared name source without starting a second watcher", async () => {
const source = { beginWatch: vi.fn(), close: vi.fn() };
const adapter = new CLIStorageEventManagerAdapter(
"/base",
undefined,
true,
undefined,
undefined,
source as unknown as CLIFileNameSource
);
const handlers = makeHandlers();
await adapter.watch.beginWatch(handlers);
expect(source.beginWatch).toHaveBeenCalledWith(handlers);
expect(chokidar.watch).not.toHaveBeenCalled();
await adapter.close();
expect(source.close).toHaveBeenCalledTimes(1);
});
it("add event produces NodeFile with correct relative path via onCreate", async () => {
const basePath = "/vault/base";
const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true);
@@ -5,6 +5,7 @@ import {
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import type { CLIFileNameSource } from "@/apps/cli/storage/CLIFileNameSource";
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
import { runtimeScopeDirectory } from "@/apps/cli/runtimeState";
@@ -20,7 +21,8 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
core: LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>,
dependencies: StorageEventManagerBaseDependencies,
ignoreRules?: IgnoreRules,
watchEnabled?: boolean
watchEnabled?: boolean,
nameSource?: CLIFileNameSource
) {
const context = core.services.context as NodeServiceContext;
const adapter = new CLIStorageEventManagerAdapter(
@@ -37,7 +39,8 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
nodePath.join(
runtimeScopeDirectory(context.databasePath, dependencies.vaultService.getVaultName(), basePath),
"storage-events.json"
)
),
nameSource
);
super(adapter, dependencies);
this.core = core;
+1 -1
View File
@@ -15,7 +15,7 @@
"buildRun": "npm run build && npm run cli --",
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
"check": "tsc -p tsconfig.json",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/runtimeState.unit.spec.ts src/apps/cli/services/NodeKeyValueDBService.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/services/storageCaseSensitivity.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.real-watch.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/journalSkip.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/runtimeState.unit.spec.ts src/apps/cli/services/NodeKeyValueDBService.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/services/storageCaseSensitivity.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.real-watch.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/journalSkip.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts src/apps/cli/storage src/apps/cli/serviceModules/FileAccessCLI.unit.spec.ts",
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
@@ -29,13 +29,28 @@ export function initialiseServiceModulesCLI(
const storageAccessManager = new StorageAccessManager();
// CLI-specific file access using Node.js FileSystemAdapter
const vaultAccess = new FileAccessCLI(basePath, {
storageAccessManager: storageAccessManager,
vaultService: services.vault,
settingService: services.setting,
APIService: services.API,
pathService: services.path,
});
const vaultAccess = new FileAccessCLI(
basePath,
{
storageAccessManager: storageAccessManager,
vaultService: services.vault,
settingService: services.setting,
APIService: services.API,
pathService: services.path,
},
watchEnabled ? { ignoreRules } : undefined
);
const nameSource = vaultAccess.fileNameSource;
if (nameSource) {
services.appLifecycle.onLoaded.addHandler(async () => {
await nameSource.prepare();
return true;
});
services.appLifecycle.onBeforeUnload.addHandler(async () => {
nameSource.stopDelivery();
return true;
});
}
// CLI-specific storage event manager
const storageEventManager = new StorageEventManagerCLI(
@@ -49,7 +64,8 @@ export function initialiseServiceModulesCLI(
APIService: services.API,
},
ignoreRules,
watchEnabled
watchEnabled,
nameSource
);
// Close the file watcher during graceful shutdown so the process can exit cleanly.
+59 -7
View File
@@ -4,14 +4,41 @@ import {
} from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
import { NodeFileSystemAdapter } from "@/apps/cli/adapters/NodeFileSystemAdapter";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
import { parseFilePath } from "@vrtmrz/livesync-commonlib/storage";
import { CLIFileNameSource } from "@/apps/cli/storage/CLIFileNameSource";
import type { StorageMutationCompletion } from "@/apps/cli/storage/ObservedNodeStorageAdapter";
import type { IgnoreRules } from "./IgnoreRules";
import { getPathNormalizationVersion } from "@vrtmrz/livesync-commonlib/settings";
const finishUnobservedMutation: StorageMutationCompletion = () => undefined;
/**
* CLI-specific implementation of FileAccessBase
* Uses NodeFileSystemAdapter for Node.js file operations
* Composes CLI file access with an optional watched name inventory.
*
* Single-run commands retain physical enumeration. Watching commands share the
* adapter's storage boundary with the name source, so local mutations invalidate
* names before chokidar has delivered their notifications.
*/
export class FileAccessCLI extends FileAccessBase<NodeFileSystemAdapter> {
constructor(basePath: string, dependencies: FileAccessBaseDependencies) {
/** Present only when this CLI instance owns the shared watcher lifecycle. */
readonly fileNameSource?: CLIFileNameSource;
constructor(
basePath: string,
dependencies: FileAccessBaseDependencies,
watchOptions?: { ignoreRules?: IgnoreRules }
) {
// The adapter must exist before the source can share its storage. This
// closure reads the source when I/O begins, after composition has finished.
let source: CLIFileNameSource | undefined;
const invalidateFileNames = (paths: readonly string[]): StorageMutationCompletion => {
if (source === undefined) {
// Single-run commands have no inventory to update; the adapter
// still receives the completion callback its mutation hook expects.
return finishUnobservedMutation;
}
return source.invalidate(paths);
};
const pathNormalizationVersion = () =>
getPathNormalizationVersion(dependencies.settingService.currentSettings());
const isCaseInsensitive = () => {
@@ -27,14 +54,39 @@ export class FileAccessCLI extends FileAccessBase<NodeFileSystemAdapter> {
}
},
pathNormalizationVersion,
isCaseInsensitive
isCaseInsensitive,
invalidateFileNames
);
super(adapter, dependencies);
// Only the watching composition publishes indexed lookup. An empty
// options object still requests watching; undefined selects single-run I/O.
if (watchOptions) {
source = new CLIFileNameSource(
adapter.storage,
() => ({
pathNormalizationVersion: getPathNormalizationVersion(
dependencies.settingService.currentSettings()
),
handleFilenameCaseSensitive: !isCaseInsensitive(),
}),
{
basePath,
ignoreRules: watchOptions.ignoreRules,
reportDiagnostic: (message, detail) => {
dependencies.APIService.addLog(message, LOG_LEVEL_NOTICE);
if (detail !== undefined) {
dependencies.APIService.addLog(detail, LOG_LEVEL_NOTICE);
}
},
suppressEvent: (path) => dependencies.storageAccessManager.isFileProcessing(parseFilePath(path)),
}
);
adapter.fileNameLookup = source;
this.fileNameSource = source;
}
}
/**
* Expose the adapter for accessing scanDirectory
*/
/** Expose the storage adapter to CLI operations which require physical scans. */
get nodeAdapter(): NodeFileSystemAdapter {
return this.adapter;
}
@@ -0,0 +1,130 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StorageAccessManager } from "@vrtmrz/livesync-commonlib/compat/managers/StorageProcessingManager";
import type { FileAccessBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
import { FilePathIndex, parseFilePath } from "@vrtmrz/livesync-commonlib/storage";
import { isAcceptedInFilenameDuplicationFactory } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter";
import { FileAccessCLI } from "./FileAccessCLI";
import { ServiceFileAccessCLI } from "./ServiceFileAccessImpl";
const roots: string[] = [];
const accesses: FileAccessCLI[] = [];
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(
accesses.splice(0).map(async (access) => {
await access.fileNameSource?.close();
})
);
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
async function fixture(version: 0 | 1, caseSensitive: boolean, watching = true) {
const root = await mkdtemp(join(tmpdir(), "livesync-name-query-"));
roots.push(root);
await mkdir(join(root, "unrelated"));
await writeFile(join(root, "note.md"), "original");
await writeFile(join(root, "unrelated", "other.md"), "other");
const settings = { pathNormalizationVersion: version, handleFilenameCaseSensitive: caseSensitive };
const dependencies = {
storageAccessManager: new StorageAccessManager(),
vaultService: { isStorageInsensitive: () => false },
settingService: { currentSettings: () => settings },
APIService: { addLog: vi.fn() },
pathService: {},
} as unknown as FileAccessBaseDependencies;
const access = new FileAccessCLI(root, dependencies, watching ? {} : undefined);
accesses.push(access);
await access.fileNameSource?.prepare();
const on = { addHandler: vi.fn() };
const processing = { totalStorageFileEventCount: 0, commitPendingFileEvents: on };
const storage = new ServiceFileAccessCLI({
API: dependencies.APIService,
vault: dependencies.vaultService,
setting: dependencies.settingService,
fileProcessing: processing,
vaultAccess: access,
storageEventManager: {},
appLifecycle: { onFirstInitialise: on, onBeforeUnload: on },
} as unknown as ConstructorParameters<typeof ServiceFileAccessCLI>[0]);
const host = {
services: {
setting: dependencies.settingService,
vault: { shouldCheckCaseInsensitively: () => !caseSensitive },
fileProcessing: processing,
},
serviceModules: { storageAccess: storage },
} as unknown as Parameters<typeof isAcceptedInFilenameDuplicationFactory>[0];
const filter = isAcceptedInFilenameDuplicationFactory(host, vi.fn());
return { root, access, storage, processing, filter };
}
describe("CLI watched names through shared storage access", () => {
it.each([
[0, true],
[0, false],
[1, true],
[1, false],
] as const)(
"does not enumerate metadata or rebuild identity maps after a content edit (version %i, case %s)",
async (version, sensitive) => {
const { access, storage, processing, filter } = await fixture(version, sensitive);
const path = parseFilePath("note.md");
expect(await filter(path)).toBe(true);
const metadata = vi.spyOn(access.nodeAdapter, "getFiles");
const rebuild = vi.spyOn(FilePathIndex.prototype, "replaceAll");
const directories = vi.spyOn(access.nodeAdapter.storage, "readDirectory");
await access.adapterWrite(path, "changed content");
processing.totalStorageFileEventCount++;
expect(await filter(path)).toBe(true);
expect(metadata).not.toHaveBeenCalled();
expect(rebuild).not.toHaveBeenCalled();
expect(directories.mock.calls.every(([directory]) => directory !== "unrelated")).toBe(true);
expect(await storage.getFileNames()).toEqual(expect.arrayContaining(["note.md", "unrelated/other.md"]));
const files = await storage.getFiles();
expect(files.find((file) => file.path === path)?.stat?.size).toBe("changed content".length);
}
);
it("sees direct adapter creation and an external alias before delayed watch delivery", async () => {
const { root, access, storage, filter } = await fixture(0, false);
await access.adapterWrite("created.bin", new Uint8Array([1, 2, 3]));
expect(await storage.getFileNames()).toContain("created.bin");
await writeFile(join(root, "NOTE.MD"), "external alias");
expect(await filter(parseFilePath("note.md"))).toBe(false);
expect(await readFile(join(root, "note.md"), "utf8")).toBe("original");
expect(await readFile(join(root, "NOTE.MD"), "utf8")).toBe("external alias");
await access.adapterRemove("created.bin");
expect(await storage.getFileNames()).not.toContain("created.bin");
});
it("reflects direct append and directory creation plus Vault rename before watch delivery", async () => {
const { root, access, storage } = await fixture(0, true);
await access.ensureDirectory("notes/later.md");
await access.adapterAppend("notes/later.md", "appended");
expect(await storage.getFileNames()).toContain("notes/later.md");
await access.ensureDirectory("archive/renamed.md");
const file = await access.getAbstractFileByPath("notes/later.md");
if (!access.isFile(file)) {
throw new Error("Expected the appended file");
}
await access.vaultRename(file, "archive/renamed.md");
const names = await storage.getFileNames();
expect(names).not.toContain("notes/later.md");
expect(names).toContain("archive/renamed.md");
expect(await readFile(join(root, "archive/renamed.md"), "utf8")).toBe("appended");
});
it("keeps non-watching commands on fresh metadata enumeration without creating a source", async () => {
const { root, access, storage } = await fixture(0, true, false);
expect(access.fileNameSource).toBeUndefined();
expect(access.nodeAdapter.fileNameLookup).toBeUndefined();
await writeFile(join(root, "later.md"), "later");
expect(await storage.getFileNames({ fresh: true })).toContain("later.md");
await access.adapterWrite("local.md", "local write");
expect(await storage.getFileNames()).toContain("local.md");
expect(await readFile(join(root, "local.md"), "utf8")).toBe("local write");
});
});
@@ -0,0 +1,283 @@
import {
createStoragePathResolver,
FilePathIndex,
getStoragePathComparisonKey,
parseFilePath,
type DirectoryEntry,
type FilePath,
type PathComparisonSettings,
type StoragePath,
type StorageEntry,
} from "@vrtmrz/livesync-commonlib/storage";
import type { UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import { isHiddenPath } from "@/apps/cli/cliPathFilter";
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
import {
asError,
getRawDirectoryEntries,
isSupportedPolicy,
joinRelative,
samePolicy,
type DirectoryListing,
type InventoryReader,
type InventoryState,
} from "./CLIFileNameSource.state";
export type InventoryReaderDependencies = {
/** The read-only listing capability used to build and validate inventories. */
readonly storage: Pick<UnicodeResolvingNodeStorageAdapter, "readDirectory">;
readonly basePath: string;
readonly reportDiagnostic: CliDiagnosticReporter;
readonly markDirectoryDirty: (directory: string) => void;
readonly recordFreshFailure: (state: InventoryState, policy: PathComparisonSettings, error: unknown) => void;
};
/**
* Create an unpublished inventory candidate with an independent policy value.
*/
export function createEmptyState(policy: PathComparisonSettings): InventoryState {
return {
index: new FilePathIndex(),
files: new Set<FilePath>(),
directories: new Map<string, readonly DirectoryEntry[]>(),
readErrors: new Map<string, Error>(),
aliasErrors: new Map<string, Error>(),
policy: { ...policy },
};
}
/**
* Bind the shared path resolver to the minimal storage read port used for sibling checks.
*/
export function createResolver(
dependencies: Pick<InventoryReaderDependencies, "storage">,
policy: PathComparisonSettings
) {
return createStoragePathResolver({
readDirectory: async (directory) => await dependencies.storage.readDirectory(directory),
policy: {
pathNormalizationVersion: 1,
handleFilenameCaseSensitive: policy.handleFilenameCaseSensitive,
},
});
}
/**
* Read original physical names in Legacy mode or resolver-validated names in NFC mode.
*/
export async function readDirectory(
dependencies: Pick<InventoryReaderDependencies, "storage">,
directory: string,
policy: PathComparisonSettings
): Promise<DirectoryListing | null> {
const physical = parseFilePath(directory) as StoragePath;
// Legacy mode reads physical spellings directly. NFC mode also validates
// sibling identities through the shared resolver before accepting coverage.
if (policy.pathNormalizationVersion !== 1) {
const entries = await dependencies.storage.readDirectory(physical);
return entries === null ? null : { kind: "legacy", entries };
}
const entries = await createResolver(dependencies, policy).listResolvedDirectory(physical);
if (entries === null) {
return null;
}
// Keep the resolver's path refinements through membership publication.
return { kind: "validated", entries };
}
/**
* Parse supported Legacy entries lazily, preserving traversal and diagnostic order.
*
* Host enumeration supplies physical spelling; parsing checks the path syntax.
* Invalid entries remain in raw coverage but never enter the file-name index.
*/
export function* parseLegacyStorageEntries(
dependencies: Pick<InventoryReaderDependencies, "reportDiagnostic">,
directory: string,
entries: readonly DirectoryEntry[]
): Generator<StorageEntry, void> {
for (const entry of entries) {
const child = joinRelative(directory, entry.name);
let childPath: StoragePath;
try {
childPath = parseFilePath(child) as StoragePath;
} catch (error) {
dependencies.reportDiagnostic("[CLIFileNameSource] Skipped invalid path '" + child + "':", error);
continue;
}
yield { path: childPath, kind: entry.kind };
}
}
/**
* Add a complete directory subtree to an unpublished candidate, retaining Legacy partial reads.
*
* Storage supplies listings, basePath identifies an unavailable root, and diagnostics report
* unreadable Legacy descendants or invalid physical names.
*/
export async function scanWholeDirectory(
dependencies: Pick<InventoryReaderDependencies, "storage" | "basePath" | "reportDiagnostic">,
state: InventoryState,
directory: string,
isRoot: boolean
): Promise<void> {
// Direct subtree scans and recursive descent share the same admission rule.
if (isHiddenPath(directory)) {
return;
}
let listing: DirectoryListing | null;
try {
listing = await readDirectory(dependencies, directory, state.policy);
} catch (error) {
const failure = asError(error);
// Legacy inventories retain partial readability; NFC inventories
// must reject unreadable scopes so their identity checks stay sound.
if (isRoot || state.policy.pathNormalizationVersion === 1) {
if (failure.name === "AmbiguousNormalisedPathError") {
state.aliasErrors.set(directory, failure);
} else {
state.readErrors.set(directory, failure);
}
throw failure;
}
state.readErrors.set(directory, failure);
dependencies.reportDiagnostic("[CLIFileNameSource] Skipped unreadable directory '" + directory + "':", failure);
return;
}
if (listing === null) {
// An absent descendant contributes no names. An absent root means
// there is no listing authority, so an empty inventory would be unsafe.
if (isRoot) {
throw new Error("The CLI file-name source root is unavailable: " + dependencies.basePath);
}
return;
}
state.directories.set(directory, getRawDirectoryEntries(directory, listing));
state.readErrors.delete(directory);
state.aliasErrors.delete(directory);
const entries =
listing.kind === "validated"
? listing.entries
: parseLegacyStorageEntries(dependencies, directory, listing.entries);
for (const entry of entries) {
if (entry.kind === "file") {
state.files.add(entry.path);
} else if (!isHiddenPath(entry.path)) {
// Supported listing excludes hidden subtrees, but includes hidden
// leaf files encountered above for collision-name coverage.
await scanWholeDirectory(dependencies, state, entry.path, false);
}
}
}
/**
* Build a fresh candidate; the optional owner callback retains failures from requested fresh scans.
*/
export async function enumerate(
dependencies: Pick<InventoryReaderDependencies, "storage" | "basePath" | "reportDiagnostic" | "recordFreshFailure">,
policy: PathComparisonSettings,
retainFailure = false
): Promise<InventoryState> {
if (!isSupportedPolicy(policy)) {
throw new Error("Unsupported path comparison policy: " + JSON.stringify(policy));
}
const state = createEmptyState(policy);
try {
await scanWholeDirectory(dependencies, state, "", true);
state.index.replaceAll(state.files);
return state;
} catch (error) {
if (retainFailure) {
// The owner records the first failing scope and keeps this
// failed fresh scan from being mistaken for an empty inventory.
dependencies.recordFreshFailure(state, policy, error);
}
throw error;
}
}
/**
* Validate cached sibling identities before publishing a policy, then dirty failed scopes for NFC rereads.
*
* This operation uses cached original names only; it performs no storage I/O.
*/
export async function selectPolicy(
dependencies: Pick<InventoryReaderDependencies, "markDirectoryDirty">,
state: InventoryState,
policy: PathComparisonSettings
): Promise<void> {
if (!isSupportedPolicy(policy)) {
throw new Error("Unsupported path comparison policy: " + JSON.stringify(policy));
}
if (samePolicy(state.policy, policy)) {
return;
}
// Alias evidence belongs to the policy, whereas cached entries preserve
// physical names. Recheck all covered siblings before publishing the policy.
const nextAliasErrors = new Map<string, Error>();
if (policy.pathNormalizationVersion === 1) {
for (const [directory, entries] of state.directories) {
const seen = new Map<string, string>();
for (const entry of entries) {
let key: string;
try {
key = getStoragePathComparisonKey(parseFilePath(entry.name), policy);
} catch (error) {
nextAliasErrors.set(directory, asError(error));
break;
}
// Different physical siblings sharing this key make the NFC
// directory ambiguous; choosing either would hide a candidate.
const previous = seen.get(key);
if (previous !== undefined) {
nextAliasErrors.set(
directory,
new Error(
"Ambiguous NFC entries under '" +
directory +
"': '" +
previous +
"' and '" +
entry.name +
"'"
)
);
break;
}
seen.set(key, entry.name);
}
}
}
// Rebuild alias evidence from the cached physical spellings before
// publishing the new policy; failed directories are then revalidated.
state.policy = { ...policy };
state.aliasErrors = nextAliasErrors;
if (policy.pathNormalizationVersion === 1) {
for (const directory of state.readErrors.keys()) {
dependencies.markDirectoryDirty(directory);
}
}
}
/**
* Bind the inventory operations to CLIFileNameSource dependencies without owning readiness or failure state.
*/
export function createInventoryReader(dependencies: InventoryReaderDependencies): InventoryReader {
const enumerationDependencies = {
storage: dependencies.storage,
basePath: dependencies.basePath,
reportDiagnostic: dependencies.reportDiagnostic,
recordFreshFailure: dependencies.recordFreshFailure,
};
const directoryDependencies = { storage: dependencies.storage };
const policyDependencies = { markDirectoryDirty: dependencies.markDirectoryDirty };
return {
enumerate: (policy, retainFailure) => enumerate(enumerationDependencies, policy, retainFailure),
selectPolicy: (state, policy) => selectPolicy(policyDependencies, state, policy),
readDirectory: (directory, policy) => readDirectory(directoryDependencies, directory, policy),
};
}
@@ -0,0 +1,291 @@
import { describe, expect, it, vi } from "vitest";
import {
parseFilePath,
type DirectoryEntry,
type PathComparisonSettings,
type StoragePath,
} from "@vrtmrz/livesync-commonlib/storage";
import {
createEmptyState,
enumerate,
readDirectory,
scanWholeDirectory,
selectPolicy,
type InventoryReaderDependencies,
} from "./CLIFileNameSource.inventory";
import type { InventoryState } from "./CLIFileNameSource.state";
function policy(pathNormalizationVersion: 0 | 1, handleFilenameCaseSensitive = true): PathComparisonSettings {
return { pathNormalizationVersion, handleFilenameCaseSensitive };
}
describe("CLIFileNameSource inventory operations", () => {
it("preserves physical spellings in Legacy listings and rejects NFC sibling aliases", async () => {
const nfcName = "caf\u00e9.md";
const nfdName = "cafe\u0301.md";
const entries: readonly DirectoryEntry[] = [
{ name: nfcName, kind: "file" },
{ name: nfdName, kind: "file" },
];
const storage = {
readDirectory: vi.fn(async (_directory: StoragePath): Promise<readonly DirectoryEntry[] | null> => entries),
};
const freshFailures: Array<{
state: InventoryState;
policy: PathComparisonSettings;
error: unknown;
}> = [];
const enumerationDependencies: Pick<
InventoryReaderDependencies,
"storage" | "basePath" | "reportDiagnostic" | "recordFreshFailure"
> = {
storage,
basePath: "/vault",
reportDiagnostic: vi.fn(),
recordFreshFailure: (state, selectedPolicy, error) => {
freshFailures.push({ state, policy: selectedPolicy, error });
},
};
await expect(readDirectory({ storage }, "", policy(0))).resolves.toEqual({ kind: "legacy", entries });
const legacyState = await enumerate(enumerationDependencies, policy(0));
expect(Array.from(legacyState.files)).toEqual([parseFilePath(nfcName), parseFilePath(nfdName)]);
await expect(readDirectory({ storage }, "", policy(1))).rejects.toThrow(/Ambiguous/);
await expect(enumerate(enumerationDependencies, policy(1), true)).rejects.toThrow(/Ambiguous/);
expect(freshFailures).toHaveLength(1);
expect(freshFailures[0]?.state.aliasErrors.has("")).toBe(true);
expect(freshFailures[0]?.policy).toEqual(policy(1));
expect(freshFailures[0]?.error).toBeInstanceOf(Error);
});
it("returns full physical paths and entry kinds for non-root NFC listings", async () => {
const nfdName = "cafe\u0301.md";
const entries: readonly DirectoryEntry[] = [
{ name: nfdName, kind: "file" },
{ name: "nested", kind: "directory" },
];
const storage = {
readDirectory: vi.fn(
async (directory: StoragePath): Promise<readonly DirectoryEntry[] | null> =>
directory === "folder" ? entries : null
),
};
await expect(readDirectory({ storage }, "folder", policy(1))).resolves.toEqual({
kind: "validated",
entries: [
{ path: "folder/" + nfdName, kind: "file" },
{ path: "folder/nested", kind: "directory" },
],
});
expect(storage.readDirectory).toHaveBeenCalledWith("folder");
});
it.each([0, 1] as const)("preserves an absent listing as null in mode %i", async (version) => {
const storage = {
readDirectory: vi.fn(async (_directory: StoragePath): Promise<readonly DirectoryEntry[] | null> => null),
};
await expect(readDirectory({ storage }, "folder", policy(version))).resolves.toBeNull();
expect(storage.readDirectory).toHaveBeenCalledWith("folder");
});
it("keeps a readable Legacy inventory after a descendant failure and retains an NFC fresh failure", async () => {
const descendantFailure = new Error("descendant listing failed");
const rootEntries: readonly DirectoryEntry[] = [
{ name: "unreadable", kind: "directory" },
{ name: "visible.md", kind: "file" },
];
const storage = {
readDirectory: vi.fn(async (directory: StoragePath): Promise<readonly DirectoryEntry[] | null> => {
if (directory === "") {
return rootEntries;
}
if (directory === "unreadable") {
throw descendantFailure;
}
return null;
}),
};
const reportDiagnostic = vi.fn();
const scanDependencies: Pick<InventoryReaderDependencies, "storage" | "basePath" | "reportDiagnostic"> = {
storage,
basePath: "/vault",
reportDiagnostic,
};
const legacyState = createEmptyState(policy(0));
await scanWholeDirectory(scanDependencies, legacyState, "", true);
expect(Array.from(legacyState.files)).toEqual([parseFilePath("visible.md")]);
expect(legacyState.readErrors.get("unreadable")).toBe(descendantFailure);
expect(reportDiagnostic).toHaveBeenCalledWith(
"[CLIFileNameSource] Skipped unreadable directory 'unreadable':",
descendantFailure
);
const freshFailures: Array<{
state: InventoryState;
policy: PathComparisonSettings;
error: unknown;
}> = [];
const enumerationDependencies: Pick<
InventoryReaderDependencies,
"storage" | "basePath" | "reportDiagnostic" | "recordFreshFailure"
> = {
...scanDependencies,
recordFreshFailure: (state, selectedPolicy, error) => {
freshFailures.push({ state, policy: selectedPolicy, error });
},
};
await expect(enumerate(enumerationDependencies, policy(1), true)).rejects.toThrow(/descendant listing failed/);
expect(freshFailures).toHaveLength(1);
expect(freshFailures[0]?.state.readErrors.has("unreadable")).toBe(true);
expect(freshFailures[0]?.policy).toEqual(policy(1));
expect(freshFailures[0]?.error).toBeInstanceOf(Error);
});
it.each([0, 1] as const)(
"treats a missing root as unavailable and an absent descendant as empty (version %i)",
async (version) => {
const rootEntries: readonly DirectoryEntry[] = [
{ name: "absent", kind: "directory" },
{ name: "visible.md", kind: "file" },
];
const descendantStorage = {
readDirectory: vi.fn(
async (directory: StoragePath): Promise<readonly DirectoryEntry[] | null> =>
directory === "" ? rootEntries : null
),
};
const reportDiagnostic = vi.fn();
const descendantState = createEmptyState(policy(version));
await scanWholeDirectory(
{ storage: descendantStorage, basePath: "/vault", reportDiagnostic },
descendantState,
"",
true
);
expect(Array.from(descendantState.files)).toEqual([parseFilePath("visible.md")]);
expect(descendantState.directories.has("absent")).toBe(false);
expect(reportDiagnostic).not.toHaveBeenCalled();
const unavailableStorage = {
readDirectory: vi.fn(
async (_directory: StoragePath): Promise<readonly DirectoryEntry[] | null> => null
),
};
const unavailableState = createEmptyState(policy(version));
await expect(
scanWholeDirectory(
{ storage: unavailableStorage, basePath: "/vault", reportDiagnostic },
unavailableState,
"",
true
)
).rejects.toThrow("The CLI file-name source root is unavailable: /vault");
expect(unavailableStorage.readDirectory).toHaveBeenCalledWith("");
expect(unavailableState.files.size).toBe(0);
}
);
it("skips an invalid Legacy leaf while retaining readable names", async () => {
const entries: readonly DirectoryEntry[] = [
{ name: "C:unsupported.md", kind: "file" },
{ name: "visible.md", kind: "file" },
];
const storage = {
readDirectory: vi.fn(async (_directory: StoragePath): Promise<readonly DirectoryEntry[] | null> => entries),
};
const reportDiagnostic = vi.fn();
const state = createEmptyState(policy(0));
await scanWholeDirectory({ storage, basePath: "/vault", reportDiagnostic }, state, "", true);
expect(Array.from(state.files)).toEqual([parseFilePath("visible.md")]);
expect(reportDiagnostic).toHaveBeenCalledWith(
expect.stringContaining("Skipped invalid path 'C:unsupported.md':"),
expect.any(Error)
);
// Legacy skips remain in physical coverage and must block NFC activation.
await selectPolicy({ markDirectoryDirty: vi.fn() }, state, policy(1));
expect(state.aliasErrors.get("")).toBeInstanceOf(Error);
expect(storage.readDirectory).toHaveBeenCalledTimes(1);
expect(Array.from(state.files)).toEqual([parseFilePath("visible.md")]);
});
it("rejects unsupported runtime policies before reading or publishing them", async () => {
const unsupportedVersion = {
pathNormalizationVersion: 2,
handleFilenameCaseSensitive: true,
} as unknown as PathComparisonSettings;
const invalidCasePolicy = {
pathNormalizationVersion: 1,
handleFilenameCaseSensitive: 1,
} as unknown as PathComparisonSettings;
const storage = {
readDirectory: vi.fn(async (_directory: StoragePath): Promise<readonly DirectoryEntry[] | null> => []),
};
const recordFreshFailure = vi.fn();
const enumerationDependencies = {
storage,
basePath: "/vault",
reportDiagnostic: vi.fn(),
recordFreshFailure,
};
for (const invalidPolicy of [unsupportedVersion, invalidCasePolicy]) {
await expect(enumerate(enumerationDependencies, invalidPolicy, true)).rejects.toThrow(
"Unsupported path comparison policy"
);
}
expect(storage.readDirectory).not.toHaveBeenCalled();
expect(recordFreshFailure).not.toHaveBeenCalled();
const state = createEmptyState(policy(0));
const markDirectoryDirty = vi.fn();
await expect(selectPolicy({ markDirectoryDirty }, state, invalidCasePolicy)).rejects.toThrow(
"Unsupported path comparison policy"
);
expect(state.policy).toEqual(policy(0));
expect(markDirectoryDirty).not.toHaveBeenCalled();
});
it("revalidates cached siblings and dirties unreadable scopes without storage access", async () => {
const nfcName = "caf\u00e9.md";
const nfdName = "cafe\u0301.md";
const state = createEmptyState(policy(0));
state.directories.set("", [
{ name: nfcName, kind: "file" },
{ name: nfdName, kind: "file" },
]);
state.readErrors.set("unreadable", new Error("prior listing failure"));
const markDirectoryDirty = vi.fn();
const dependencies: Pick<InventoryReaderDependencies, "markDirectoryDirty"> = { markDirectoryDirty };
await selectPolicy(dependencies, state, policy(1));
expect(state.policy).toEqual(policy(1));
expect(state.aliasErrors.get("")).toBeInstanceOf(Error);
expect(state.aliasErrors.get("")?.message).toContain(nfcName);
expect(state.aliasErrors.get("")?.message).toContain(nfdName);
expect(markDirectoryDirty).toHaveBeenCalledTimes(1);
expect(markDirectoryDirty).toHaveBeenCalledWith("unreadable");
await selectPolicy(dependencies, state, policy(0));
expect(state.aliasErrors.size).toBe(0);
state.directories.set("", [{ name: nfcName, kind: "file" }]);
await selectPolicy(dependencies, state, policy(1));
expect(state.policy).toEqual(policy(1));
expect(state.aliasErrors.size).toBe(0);
expect(markDirectoryDirty).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,200 @@
import { EventEmitter } from "node:events";
import { fs, os, path } from "@vrtmrz/livesync-commonlib/node";
import { parseFilePath, type PathComparisonSettings } from "@vrtmrz/livesync-commonlib/storage";
import type { FSWatcher } from "chokidar";
import { describe, expect, it, vi } from "vitest";
import { NodeFileSystemAdapter } from "@/apps/cli/adapters/NodeFileSystemAdapter";
import { CLIFileNameSource } from "./CLIFileNameSource";
import { ObservedNodeStorageAdapter } from "./ObservedNodeStorageAdapter";
async function withSource(
version: 0 | 1,
run: (fixture: {
root: string;
source: CLIFileNameSource;
storage: ObservedNodeStorageAdapter;
policy: PathComparisonSettings;
prepare: () => Promise<void>;
listExpected: () => Promise<string[]>;
}) => Promise<void>
) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-name-membership-"));
const policy: PathComparisonSettings = { pathNormalizationVersion: version, handleFilenameCaseSensitive: true };
const watcher = Object.assign(new EventEmitter(), { close: async () => undefined });
let source: CLIFileNameSource;
const storage = new ObservedNodeStorageAdapter(
root,
() => version,
() => false,
(paths) => source.invalidate(paths)
);
source = new CLIFileNameSource(storage, () => policy, {
basePath: root,
watcherFactory: () => watcher as unknown as FSWatcher,
});
const expected = new NodeFileSystemAdapter(root, undefined, () => version);
try {
await run({
root,
source,
storage,
policy,
prepare: async () => {
const pending = source.prepare();
watcher.emit("ready");
await pending;
},
listExpected: async () => (await expected.getFiles()).map((file) => file.path).sort(),
});
} finally {
await source.close();
fs.rmSync(root, { recursive: true, force: true });
}
}
describe("CLI watched membership and the supported physical listing", () => {
it("skips excluded Legacy directory siblings when selecting a supported parent", async () => {
await withSource(0, async ({ root, source, policy, prepare, listExpected }) => {
fs.mkdirSync(path.join(root, "C:unsupported"));
fs.mkdirSync(path.join(root, "docs"));
fs.writeFileSync(path.join(root, "docs/note.md"), "safe");
await prepare();
expect(await source.getFileNames()).toEqual(await listExpected());
await expect(source.findPathIdentityCandidates(parseFilePath("docs/note.md"), policy)).resolves.toEqual([
"docs/note.md",
]);
expect(fs.readFileSync(path.join(root, "docs/note.md"), "utf8")).toBe("safe");
});
});
it.each([0, 1] as const)(
"keeps hidden-directory mutations outside membership, with hidden leaf files retained (version %i)",
async (version) => {
await withSource(version, async ({ root, source, storage, prepare, listExpected }) => {
fs.mkdirSync(path.join(root, ".hidden"));
fs.mkdirSync(path.join(root, "notes/.private"), { recursive: true });
fs.writeFileSync(path.join(root, "safe.md"), "safe");
await prepare();
await storage.write(".hidden/internal.md", "private");
await storage.write("notes/.private/nested.md", "nested");
await storage.write(".visible.md", "supported leaf");
await storage.write("notes/.leaf.md", "supported nested leaf");
expect((await source.getFileNames()).sort()).toEqual(await listExpected());
expect((await source.getFileNames()).sort()).toEqual([".visible.md", "notes/.leaf.md", "safe.md"]);
await storage.rename(".hidden/internal.md", "notes/moved.md");
expect((await source.getFileNames()).sort()).toEqual(await listExpected());
await storage.rename("notes/moved.md", ".hidden/returned.md");
expect((await source.getFileNames()).sort()).toEqual(await listExpected());
expect((await source.getFileNames({ fresh: true })).sort()).toEqual(await listExpected());
expect(fs.readFileSync(path.join(root, ".hidden/returned.md"), "utf8")).toBe("private");
});
}
);
it.each(["write", "writeBinary", "append", "mkdir"] as const)(
"updates an NFD physical parent after logical NFC %s before watcher delivery",
async (operation) => {
await withSource(1, async ({ root, source, storage, prepare, listExpected }) => {
fs.mkdirSync(path.join(root, "cafe\u0301"));
fs.mkdirSync(path.join(root, "unrelated"));
fs.writeFileSync(path.join(root, "cafe\u0301/existing.md"), "old");
fs.writeFileSync(path.join(root, "unrelated/keep.md"), "unrelated");
await prepare();
const reads = vi.spyOn(storage, "readDirectory");
const target = "caf\u00e9/new/note.md";
if (operation === "writeBinary") {
await storage.writeBinary(target, new Uint8Array([1, 2, 3]).buffer);
} else if (operation === "mkdir") {
await storage.mkdir("caf\u00e9/new");
await storage.write(target, "new");
} else {
await storage[operation](target, "new");
}
expect((await source.getFileNames()).sort()).toEqual(await listExpected());
expect(reads.mock.calls.some(([directory]) => directory === "unrelated")).toBe(false);
expect(fs.existsSync(path.join(root, "cafe\u0301/new/note.md"))).toBe(true);
});
}
);
it("retires and adds physical memberships for logical NFC rename and removal", async () => {
await withSource(1, async ({ root, source, storage, prepare, listExpected }) => {
fs.mkdirSync(path.join(root, "cafe\u0301"));
fs.mkdirSync(path.join(root, "re\u0301sume\u0301"));
fs.writeFileSync(path.join(root, "cafe\u0301/old.md"), "preserved");
await prepare();
await storage.rename("caf\u00e9/old.md", "r\u00e9sum\u00e9/new.md");
expect((await source.getFileNames()).sort()).toEqual(await listExpected());
expect(await source.getFileNames()).toEqual(["re\u0301sume\u0301/new.md"]);
await storage.remove("r\u00e9sum\u00e9/new.md");
expect(await source.getFileNames()).toEqual([]);
expect(await source.getFileNames()).toEqual(await listExpected());
});
});
it("replays a logical invalidation captured before initial physical coverage was published", async () => {
await withSource(1, async ({ root, source, storage, prepare, listExpected }) => {
fs.mkdirSync(path.join(root, "cafe\u0301"));
fs.writeFileSync(path.join(root, "cafe\u0301/existing.md"), "old");
const originalRead = storage.readDirectory.bind(storage);
let mutated = false;
vi.spyOn(storage, "readDirectory").mockImplementation(async (directory) => {
const entries = await originalRead(directory);
if (directory === "cafe\u0301" && !mutated) {
mutated = true;
await storage.write("caf\u00e9/added.md", "new");
}
return entries;
});
await prepare();
expect((await source.getFileNames()).sort()).toEqual(await listExpected());
});
});
it("retries a physical-parent read superseded by a logical NFC mutation", async () => {
await withSource(1, async ({ root, source, storage, policy, prepare }) => {
fs.mkdirSync(path.join(root, "cafe\u0301"));
fs.writeFileSync(path.join(root, "cafe\u0301/existing.md"), "old");
await prepare();
const originalRead = storage.readDirectory.bind(storage);
let mutated = false;
vi.spyOn(storage, "readDirectory").mockImplementation(async (directory) => {
const entries = await originalRead(directory);
if (directory === "cafe\u0301" && !mutated) {
mutated = true;
await storage.write("caf\u00e9/added.md", "new");
}
return entries;
});
await expect(
source.findPathIdentityCandidates(parseFilePath("caf\u00e9/added.md"), policy)
).resolves.toEqual(["cafe\u0301/added.md"]);
});
});
it("invalidates the physical parent when NFC I/O overrides the default case rule", async () => {
await withSource(1, async ({ root, source, storage, prepare, listExpected }) => {
fs.mkdirSync(path.join(root, "Cafe\u0301"));
fs.writeFileSync(path.join(root, "Cafe\u0301/existing.md"), "old");
await prepare();
await storage.write("CAF\u00c9/added.md", "new", undefined, true);
expect((await source.getFileNames()).sort()).toEqual(await listExpected());
expect(await source.getFileNames()).toContain("Cafe\u0301/added.md");
});
});
it("retires whole subtrees after logical NFC directory rename and deletion", async () => {
await withSource(1, async ({ root, source, storage, prepare, listExpected }) => {
fs.mkdirSync(path.join(root, "cafe\u0301/sub"), { recursive: true });
fs.mkdirSync(path.join(root, "re\u0301sume\u0301"));
fs.writeFileSync(path.join(root, "cafe\u0301/sub/note.md"), "preserved");
await prepare();
await storage.rename("caf\u00e9/sub", "r\u00e9sum\u00e9/moved");
expect((await source.getFileNames()).sort()).toEqual(await listExpected());
expect(await source.getFileNames()).toEqual(["re\u0301sume\u0301/moved/note.md"]);
expect(fs.readFileSync(path.join(root, "re\u0301sume\u0301/moved/note.md"), "utf8")).toBe("preserved");
await storage.remove("r\u00e9sum\u00e9/moved");
expect(await source.getFileNames()).toEqual([]);
expect(await source.getFileNames()).toEqual(await listExpected());
});
});
});
@@ -0,0 +1,77 @@
import {
getStoragePathComparisonKey,
parseFilePath,
type DirectoryEntry,
type FilePath,
type PathComparisonSettings,
} from "@vrtmrz/livesync-commonlib/storage";
import { isHiddenPath } from "@/apps/cli/cliPathFilter";
import { joinRelative } from "./CLIFileNameSource.state";
/** Select supported physical child directories while retaining raw entries elsewhere for NFC validation. */
export function getSupportedChildDirectories(parent: string, entries: readonly DirectoryEntry[]): FilePath[] {
const directories: FilePath[] = [];
for (const entry of entries) {
if (entry.kind !== "directory") {
continue;
}
const child = joinRelative(parent, entry.name);
if (isHiddenPath(child)) {
continue;
}
try {
directories.push(parseFilePath(child));
} catch {
// Legacy enumeration excludes these names. Their raw coverage must
// still participate when the owner later selects an NFC policy.
}
}
return directories;
}
/**
* Locate physical scopes for an invalidation using retained directory entries only.
*
* A missing or uncovered component selects its nearest known ancestor, whose
* next read can discover new subtrees. NFC mutations may explicitly override
* case handling, so invalidate every possible case-equivalent branch. Legacy
* scopes use the effective host case policy, including insensitive storage. This is an invalidation hint,
* not path resolution or permission to perform filesystem I/O.
*/
export function findInvalidatedDirectoryScopes(
directories: ReadonlyMap<string, readonly DirectoryEntry[]>,
requested: string,
policy: PathComparisonSettings
): Set<string> {
const scopes = new Set<string>();
if (isHiddenPath(requested)) {
return scopes;
}
const comparisonPolicy = {
...policy,
handleFilenameCaseSensitive: policy.pathNormalizationVersion === 0 && policy.handleFilenameCaseSensitive,
};
let branches = new Set([""]);
for (const segment of requested === "" ? [] : requested.split("/")) {
const nextBranches = new Set<string>();
for (const parent of branches) {
const entries = directories.get(parent) ?? [];
const key = getStoragePathComparisonKey(parseFilePath(joinRelative(parent, segment)), comparisonPolicy);
const matches = getSupportedChildDirectories(parent, entries).filter(
(child) => getStoragePathComparisonKey(child, comparisonPolicy) === key
);
if (matches.length === 0) {
scopes.add(parent);
} else {
for (const child of matches) {
nextBranches.add(child);
}
}
}
branches = nextBranches;
}
for (const directory of branches) {
scopes.add(directory);
}
return scopes;
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import type { DirectoryEntry } from "@vrtmrz/livesync-commonlib/storage";
import { findInvalidatedDirectoryScopes } from "./CLIFileNameSource.paths";
describe("physical directory scopes for invalidation", () => {
it.each([true, false])(
"retains every Legacy parent admitted by the effective case policy (sensitive: %s)",
(sensitive) => {
const directories = new Map<string, readonly DirectoryEntry[]>([
[
"",
[
{ name: "Docs", kind: "directory" },
{ name: "docs", kind: "directory" },
{ name: "other", kind: "directory" },
],
],
["Docs", []],
["docs", []],
["other", []],
]);
const scopes = findInvalidatedDirectoryScopes(directories, "docs", {
pathNormalizationVersion: 0,
handleFilenameCaseSensitive: sensitive,
});
expect([...scopes].sort()).toEqual(sensitive ? ["docs"] : ["Docs", "docs"]);
}
);
});
@@ -0,0 +1,540 @@
import {
getStoragePathComparisonKey,
parseFilePath,
type DirectoryEntry,
type FilePath,
type PathComparisonSettings,
} from "@vrtmrz/livesync-commonlib/storage";
import { isHiddenPath } from "@/apps/cli/cliPathFilter";
import { findInvalidatedDirectoryScopes, getSupportedChildDirectories } from "./CLIFileNameSource.paths";
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
import {
asError,
depth,
joinRelative,
getRawDirectoryEntries,
type DirectoryListing,
type InventoryReader,
type InventoryReconciler,
type InventoryState,
type RefreshChanges,
} from "./CLIFileNameSource.state";
/**
* Full owner ports for target validation and factory composition. Target reads
* also commit coverage under the source-selected policy, using every port.
*/
export type InventoryReconcilerDependencies = {
readonly reader: Pick<InventoryReader, "readDirectory">;
readonly basePath: string;
readonly reportDiagnostic: CliDiagnosticReporter;
/** Source-owned pending scopes, keyed by their first unhandled observation sequence. */
readonly invalidatedDirectories: Map<string, number>;
/** Latest source observation sequence per scope, used to reject superseded reads. */
readonly directoryGenerations: Map<string, number>;
readonly getBoundary: () => number;
readonly markDirectoryDirty: (directory: string) => void;
readonly assertNotClosed: () => void;
readonly assertInventoryAvailable: (state: InventoryState, policy: PathComparisonSettings) => void;
readonly onValidatedDirectories: (policy: PathComparisonSettings, directories: ReadonlySet<string>) => void;
};
/** Ports used while reading and staging one directory; publication is separate. */
export type RefreshDirectoryDependencies = Pick<
InventoryReconcilerDependencies,
"basePath" | "reportDiagnostic" | "directoryGenerations" | "markDirectoryDirty" | "getBoundary"
> & {
readonly reader: Pick<InventoryReader, "readDirectory">;
};
/** Mutable owner bookkeeping touched only when a validated transition commits. */
export type CommitRefreshDependencies = Pick<
InventoryReconcilerDependencies,
"assertNotClosed" | "onValidatedDirectories" | "invalidatedDirectories" | "directoryGenerations"
>;
/** Captured-scope reconciliation reads and conditionally commits the same transition. */
export type ReconcilePendingDependencies = RefreshDirectoryDependencies & CommitRefreshDependencies;
/** Invalid Legacy leaf names need diagnostics; membership itself is explicit input. */
export type StageFileMembershipChangesDependencies = Pick<InventoryReconcilerDependencies, "reportDiagnostic">;
/** Create an unpublished transition which can be discarded after a superseded read. */
export function createRefreshChanges(): RefreshChanges {
return {
directoryUpdates: new Map<string, readonly DirectoryEntry[]>(),
removedDirectories: new Set<string>(),
removedFiles: new Set<FilePath>(),
addedFiles: new Set<FilePath>(),
readErrors: new Map<string, Error>(),
aliasErrors: new Map<string, Error>(),
clearedErrors: new Set<string>(),
checkedGenerations: new Map<string, number>(),
};
}
/** Read this transition's staged view first, then the last published listing. */
export function currentEntries(
state: InventoryState,
directory: string,
changes: RefreshChanges
): readonly DirectoryEntry[] {
if (changes.removedDirectories.has(directory)) {
return [];
}
return changes.directoryUpdates.get(directory) ?? state.directories.get(directory) ?? [];
}
/** A staged removal overrides older coverage when deciding whether a child needs population. */
export function hasDirectoryCoverage(state: InventoryState, directory: string, changes: RefreshChanges): boolean {
return (
!changes.removedDirectories.has(directory) &&
(changes.directoryUpdates.has(directory) || state.directories.has(directory))
);
}
/** Retire cached descendants without I/O; publication still waits for generation checks. */
export function stageRemoveSubtree(
state: InventoryState,
directory: string,
changes: RefreshChanges,
visited: Set<string>
): void {
if (visited.has(directory)) {
return;
}
visited.add(directory);
const entries = currentEntries(state, directory, changes);
for (const entry of entries) {
const child = joinRelative(directory, entry.name);
if (entry.kind === "file") {
try {
changes.removedFiles.add(parseFilePath(child));
} catch {
// Invalid legacy names were never indexed.
}
} else if (!entry.name.startsWith(".")) {
stageRemoveSubtree(state, child, changes, visited);
}
}
changes.directoryUpdates.delete(directory);
changes.removedDirectories.add(directory);
changes.clearedErrors.add(directory);
changes.readErrors.delete(directory);
changes.aliasErrors.delete(directory);
}
/**
* Fence retired descendants against observations newer than the parent read.
*
* Generations are latest observation sequences. Only removed subtrees need this
* check; unchanged descendants require no traversal or generation snapshot.
*/
export function fenceRemovedDirectories(
directoryGenerations: ReadonlyMap<string, number>,
changes: RefreshChanges,
readBoundary: number
): void {
for (const directory of changes.removedDirectories) {
if (changes.checkedGenerations.has(directory)) {
continue;
}
const generation = directoryGenerations.get(directory) ?? 0;
// A newer descendant cannot be retired using its parent's older listing.
// The existing publication check rejects this sentinel and retains work.
changes.checkedGenerations.set(directory, generation > readBoundary ? -1 : generation);
}
}
/**
* Publish a transition after its caller has checked the captured generations.
*
* The owner must still be open. Only invalidations within this read boundary can
* clear; newer notifications retain their generation and pending work.
*/
export function commitRefresh(
dependencies: CommitRefreshDependencies,
state: InventoryState,
changes: RefreshChanges,
boundary: number
): void {
dependencies.assertNotClosed();
for (const directory of changes.removedDirectories) {
state.directories.delete(directory);
state.readErrors.delete(directory);
state.aliasErrors.delete(directory);
}
for (const [directory, entries] of changes.directoryUpdates) {
state.directories.set(directory, entries);
}
for (const directory of changes.clearedErrors) {
state.readErrors.delete(directory);
state.aliasErrors.delete(directory);
}
// Clear owner-held failure evidence only after the validated scopes
// have replaced old errors, and before this transition adds new errors.
dependencies.onValidatedDirectories(state.policy, changes.clearedErrors);
for (const [directory, error] of changes.readErrors) {
state.readErrors.set(directory, error);
state.aliasErrors.delete(directory);
}
for (const [directory, error] of changes.aliasErrors) {
state.aliasErrors.set(directory, error);
}
// A path removed and recreated within this transition keeps its identity
// membership. Only actual set changes need to update the shared buckets.
const removed = Array.from(changes.removedFiles).filter(
(file) => !changes.addedFiles.has(file) && state.files.has(file)
);
const added = Array.from(changes.addedFiles).filter(
(file) => !changes.removedFiles.has(file) && !state.files.has(file)
);
for (const file of removed) {
state.files.delete(file);
}
for (const file of added) {
state.files.add(file);
}
if (removed.length > 0 || added.length > 0) {
state.index.apply({ removed, added });
}
// Acknowledge only observations covered by this read and its caller's
// boundary. This acknowledgement fence preserves later notifications even
// after the caller has checked the transition's publication generations.
for (const [directory, generation] of changes.checkedGenerations) {
if ((dependencies.directoryGenerations.get(directory) ?? 0) !== generation) {
continue;
}
const firstSequence = dependencies.invalidatedDirectories.get(directory);
if (firstSequence !== undefined && firstSequence <= boundary) {
dependencies.invalidatedDirectories.delete(directory);
}
if (changes.removedDirectories.has(directory) && (firstSequence === undefined || firstSequence <= boundary)) {
dependencies.directoryGenerations.delete(directory);
}
}
// Removing a parent also retires cached descendant generations. Keep a
// descendant's newer invalidation if it arrived after the captured boundary.
for (const directory of changes.removedDirectories) {
const checkedGeneration = changes.checkedGenerations.get(directory);
if (
checkedGeneration !== undefined &&
(dependencies.directoryGenerations.get(directory) ?? 0) !== checkedGeneration
) {
continue;
}
const firstSequence = dependencies.invalidatedDirectories.get(directory);
if (firstSequence === undefined || firstSequence <= boundary) {
dependencies.invalidatedDirectories.delete(directory);
dependencies.directoryGenerations.delete(directory);
}
}
}
/** Compare captured reads with live counters; an unobserved scope starts at generation zero. */
export function generationsUnchanged(
directoryGenerations: ReadonlyMap<string, number>,
generations: ReadonlyMap<string, number>
): boolean {
for (const [directory, generation] of generations) {
if ((directoryGenerations.get(directory) ?? 0) !== generation) {
return false;
}
}
return true;
}
/**
* Stage exact-name deltas, retaining validated paths and prior Legacy exclusions.
*
* Only newly added raw Legacy names need parsing and diagnostics. Old invalid
* names may remain in coverage after a policy change but were never indexed.
*/
export function stageFileMembershipChanges(
dependencies: StageFileMembershipChangesDependencies,
directory: string,
previous: readonly DirectoryEntry[],
listing: DirectoryListing,
changes: RefreshChanges
): void {
const previousFiles = new Set(
previous.filter((entry) => entry.kind === "file").map((entry) => joinRelative(directory, entry.name))
);
const currentFiles = new Set<string>(
listing.kind === "validated"
? listing.entries.filter((entry) => entry.kind === "file").map((entry) => entry.path)
: listing.entries
.filter((entry) => entry.kind === "file")
.map((entry) => joinRelative(directory, entry.name))
);
for (const file of previousFiles) {
if (!currentFiles.has(file)) {
try {
changes.removedFiles.add(parseFilePath(file));
} catch {
// Prior Legacy coverage can contain invalid names which were never indexed.
}
}
}
if (listing.kind === "validated") {
// NFC resolution already checked these paths. Keep their original brand
// instead of rebuilding and parsing strings from cached leaf names.
for (const entry of listing.entries) {
if (entry.kind === "file" && !previousFiles.has(entry.path)) {
changes.addedFiles.add(entry.path);
}
}
return;
}
for (const file of currentFiles) {
if (!previousFiles.has(file)) {
try {
changes.addedFiles.add(parseFilePath(file));
} catch (error) {
dependencies.reportDiagnostic("[CLIFileNameSource] Skipped invalid path '" + file + "':", error);
}
}
}
}
/**
* Stage one scope: validate a listing, diff its files, then reconcile its children.
*
* Failed reads retain previous membership and stage an error. Missing descendants
* retire cached subtrees; a missing root is unavailable coverage. Target-only
* queries defer newly discovered subtrees, while full reconciliation expands them.
* The caller publishes this transition only if all captured generations still match.
*/
export async function refreshDirectory(
dependencies: RefreshDirectoryDependencies,
state: InventoryState,
directory: string,
changes: RefreshChanges,
expandNewSubtrees: boolean,
visiting: Set<string>
): Promise<void> {
// Avoid re-entering a scope already on the recursion stack. Capture its
// generation before I/O so a later observation can reject stale work.
if (visiting.has(directory)) {
return;
}
visiting.add(directory);
const readBoundary = dependencies.getBoundary();
changes.checkedGenerations.set(directory, dependencies.directoryGenerations.get(directory) ?? 0);
// Direct dirty scopes obey the same listing boundary as recursive scans.
// Retire any old coverage without reading an excluded hidden subtree.
if (isHiddenPath(directory)) {
stageRemoveSubtree(state, directory, changes, new Set<string>());
fenceRemovedDirectories(dependencies.directoryGenerations, changes, readBoundary);
visiting.delete(directory);
return;
}
// Validate current immediate children without changing the published view.
let listing: DirectoryListing | null;
try {
listing = await dependencies.reader.readDirectory(directory, state.policy);
} catch (error) {
const failure = asError(error);
// Identity ambiguity and unreadable coverage are retained separately:
// policy changes can re-evaluate aliases without erasing failed I/O.
if (failure.name === "AmbiguousNormalisedPathError") {
changes.aliasErrors.set(directory, failure);
} else {
changes.readErrors.set(directory, failure);
}
// Retain failed coverage for the owner to reject when required by the
// selected policy. Initial full enumeration has its own strict reader.
dependencies.reportDiagnostic("[CLIFileNameSource] Could not validate directory '" + directory + "':", failure);
visiting.delete(directory);
return;
}
if (listing === null) {
// Disappearance is a removal only below the root. Losing the root
// cannot turn a formerly populated Vault into a validated empty one.
if (directory === "") {
const failure = new Error("The CLI file-name source root is unavailable: " + dependencies.basePath);
changes.readErrors.set(directory, failure);
visiting.delete(directory);
return;
}
stageRemoveSubtree(state, directory, changes, new Set<string>());
changes.clearedErrors.add(directory);
fenceRemovedDirectories(dependencies.directoryGenerations, changes, readBoundary);
visiting.delete(directory);
return;
}
// This scope validated successfully; stage its listing and clear only
// its old failures. File membership changes preserve original spellings.
const entries = getRawDirectoryEntries(directory, listing);
const previous = currentEntries(state, directory, changes);
changes.directoryUpdates.set(directory, entries);
changes.clearedErrors.add(directory);
changes.aliasErrors.delete(directory);
changes.readErrors.delete(directory);
stageFileMembershipChanges(dependencies, directory, previous, listing, changes);
// Retire vanished children before considering new or uncovered subtrees.
const previousDirectories = new Set(getSupportedChildDirectories(directory, previous));
const currentDirectories = new Set(getSupportedChildDirectories(directory, entries));
for (const child of previousDirectories) {
if (!currentDirectories.has(child)) {
stageRemoveSubtree(state, child, changes, new Set<string>());
}
}
for (const child of currentDirectories) {
// Existing covered children are handled by their own invalidations.
// A new/uncovered child needs population, but target-only queries leave
// that recursive work pending instead of scanning unrelated descendants.
if (!previousDirectories.has(child) || !hasDirectoryCoverage(state, child, changes)) {
if (expandNewSubtrees) {
await refreshDirectory(dependencies, state, child, changes, true, visiting);
} else {
dependencies.markDirectoryDirty(child);
}
}
}
fenceRemovedDirectories(dependencies.directoryGenerations, changes, readBoundary);
visiting.delete(directory);
}
/** Reconcile the captured scopes once, retaining those superseded during their reads. */
export async function reconcilePending(
dependencies: ReconcilePendingDependencies,
state: InventoryState,
boundary: number,
expandNewSubtrees = true
): Promise<Set<string>> {
const unresolved = new Set<string>();
// Parents can remove or repopulate descendants, so process shallower
// scopes first and skip descendants already settled by those transitions.
const scopes = Array.from(dependencies.invalidatedDirectories.entries())
.filter(([, firstSequence]) => firstSequence <= boundary)
.map(([directory]) => directory)
.sort((left, right) => depth(left) - depth(right));
for (const directory of scopes) {
if (!dependencies.invalidatedDirectories.has(directory)) {
continue;
}
const changes = createRefreshChanges();
const readBoundary = dependencies.getBoundary();
// Startup mutations can arrive before physical coverage is published.
// Resolve their requested scopes against this candidate too, retaining
// the original generation until every corresponding physical read commits.
changes.checkedGenerations.set(directory, dependencies.directoryGenerations.get(directory) ?? 0);
const physicalScopes = findInvalidatedDirectoryScopes(state.directories, directory, state.policy);
for (const physicalDirectory of physicalScopes) {
await refreshDirectory(
dependencies,
state,
physicalDirectory,
changes,
expandNewSubtrees,
new Set<string>()
);
}
if (!physicalScopes.has(directory)) {
// A logical alias or excluded scope owns no physical coverage. Its
// pending generation must not accumulate after acknowledgement.
stageRemoveSubtree(state, directory, changes, new Set<string>());
}
fenceRemovedDirectories(dependencies.directoryGenerations, changes, readBoundary);
// An observed change during any staged read invalidates the whole
// transition; retain its scopes for a later query or requested pass.
if (!generationsUnchanged(dependencies.directoryGenerations, changes.checkedGenerations)) {
unresolved.add(directory);
continue;
}
commitRefresh(dependencies, state, changes, boundary);
}
return unresolved;
}
/** Validate each equivalent physical parent without recursively expanding unrelated new subtrees. */
export async function revalidateTargetBranches(
dependencies: InventoryReconcilerDependencies,
state: InventoryState,
target: FilePath
): Promise<Map<string, number>> {
// The source selected this policy before entering its serialised operation.
const policy = state.policy;
const segments = String(target).split("/");
const parentSegments = segments.slice(0, -1);
let branches = [""];
const generations = new Map<string, number>();
// Follow every supported physical branch matching each logical parent segment.
// Hidden subtrees stay outside the enumerator's scope; a query must not admit
// their descendants into the shared inventory. Keep all supported case variants.
for (const segment of parentSegments) {
const nextBranches = new Set<string>();
for (const parent of branches) {
const generation = await refreshSingleDirectory(dependencies, state, parent);
generations.set(parent, generation);
const entries = state.directories.get(parent) ?? [];
const key = getStoragePathComparisonKey(parseFilePath(joinRelative(parent, segment)), policy);
for (const child of getSupportedChildDirectories(parent, entries)) {
if (getStoragePathComparisonKey(child, policy) === key) {
nextBranches.add(child);
}
}
}
branches = Array.from(nextBranches);
if (branches.length === 0) {
break;
}
}
for (const parent of branches) {
const generation = await refreshSingleDirectory(dependencies, state, parent);
generations.set(parent, generation);
}
dependencies.assertInventoryAvailable(state, policy);
return generations;
}
/**
* Validate and publish one branch, recording a direct failure before rejecting it.
* The owner has selected state.policy before entering this operation. A
* superseded read returns a non-matching generation so the target query retries.
*/
export async function refreshSingleDirectory(
dependencies: InventoryReconcilerDependencies,
state: InventoryState,
directory: string
): Promise<number> {
const changes = createRefreshChanges();
await refreshDirectory(dependencies, state, directory, changes, false, new Set<string>());
if (!generationsUnchanged(dependencies.directoryGenerations, changes.checkedGenerations)) {
// Generations are non-negative. This sentinel makes the caller retry
// rather than accept a branch whose validation was superseded.
return -1;
}
const directError = changes.aliasErrors.get(directory) ?? changes.readErrors.get(directory);
commitRefresh(dependencies, state, changes, dependencies.getBoundary());
if (directError) {
throw directError;
}
dependencies.assertInventoryAvailable(state, state.policy);
return dependencies.directoryGenerations.get(directory) ?? 0;
}
/**
* Bind the owner ports to the standalone operations without hiding their logic.
*
* Inventory state and staged changes remain explicit inputs. No mutable state is
* created here, so direct operation tests can supply only the ports they exercise.
*/
export function createInventoryReconciler(dependencies: InventoryReconcilerDependencies): InventoryReconciler {
return {
reconcilePending: (state, boundary, expandNewSubtrees) =>
reconcilePending(dependencies, state, boundary, expandNewSubtrees),
revalidateTargetBranches: (state, target) => revalidateTargetBranches(dependencies, state, target),
generationsUnchanged: (generations) => generationsUnchanged(dependencies.directoryGenerations, generations),
};
}
@@ -0,0 +1,527 @@
import { describe, expect, it, vi } from "vitest";
import {
FilePathIndex,
parseFilePath,
type DirectoryEntry,
type PathComparisonSettings,
type StoragePath,
} from "@vrtmrz/livesync-commonlib/storage";
import type { DirectoryListing, InventoryReader, InventoryState } from "./CLIFileNameSource.state";
import { readDirectory as readInventoryDirectory } from "./CLIFileNameSource.inventory";
import {
commitRefresh,
createRefreshChanges,
currentEntries,
stageRemoveSubtree,
stageFileMembershipChanges,
generationsUnchanged,
reconcilePending,
refreshDirectory,
refreshSingleDirectory,
revalidateTargetBranches,
type CommitRefreshDependencies,
type ReconcilePendingDependencies,
type RefreshDirectoryDependencies,
type InventoryReconcilerDependencies,
} from "./CLIFileNameSource.reconciliation";
const legacyPolicy: PathComparisonSettings = {
pathNormalizationVersion: 0,
handleFilenameCaseSensitive: true,
};
const file = (name: string): DirectoryEntry => ({ name, kind: "file" });
const directory = (name: string): DirectoryEntry => ({ name, kind: "directory" });
const legacyListing = (entries: readonly DirectoryEntry[]): DirectoryListing => ({ kind: "legacy", entries });
/** Produce validated fixture paths through the same shared resolver as the real reader. */
function readerForListings(listings: ReadonlyMap<string, readonly DirectoryEntry[]>) {
const storage = {
readDirectory: (directory: StoragePath) => Promise.resolve(listings.get(directory) ?? null),
};
return vi.fn<InventoryReader["readDirectory"]>((directory, policy) =>
readInventoryDirectory({ storage }, directory, policy)
);
}
function inventory(
names: readonly string[],
listings: readonly (readonly [string, readonly DirectoryEntry[]])[],
policy = legacyPolicy
): InventoryState {
const files = new Set(names.map((name) => parseFilePath(name)));
const index = new FilePathIndex();
index.replaceAll(files);
return {
index,
files,
directories: new Map(listings),
readErrors: new Map(),
aliasErrors: new Map(),
policy: { ...policy },
};
}
describe("CLI name reconciliation operations", () => {
it("stages a target-only refresh without publishing or recursively reading new subtrees", async () => {
const original = [file("old.md"), file("keep.md")];
const state = inventory(["old.md", "keep.md"], [["", original]]);
const entries = [file("keep.md"), file("new.md"), file(".hidden.md"), directory("later"), directory(".config")];
const readDirectory = vi.fn<InventoryReader["readDirectory"]>().mockResolvedValue(legacyListing(entries));
const markDirectoryDirty = vi.fn();
const directoryGenerations = new Map([["", 4]]);
const dependencies: RefreshDirectoryDependencies = {
reader: { readDirectory },
basePath: "/vault",
reportDiagnostic: vi.fn(),
directoryGenerations,
getBoundary: () => Math.max(0, ...directoryGenerations.values()),
markDirectoryDirty,
};
const changes = createRefreshChanges();
await refreshDirectory(dependencies, state, "", changes, false, new Set());
expect(state.index.getFileNames().sort()).toEqual(["keep.md", "old.md"]);
expect(state.directories.get("")).toBe(original);
expect(readDirectory).toHaveBeenCalledExactlyOnceWith("", legacyPolicy);
expect(markDirectoryDirty).toHaveBeenCalledExactlyOnceWith("later");
const invalidatedDirectories = new Map([["", 1]]);
const commitDependencies: CommitRefreshDependencies = {
assertNotClosed: vi.fn(),
onValidatedDirectories: vi.fn(),
invalidatedDirectories,
directoryGenerations,
};
commitRefresh(commitDependencies, state, changes, 1);
expect(state.index.getFileNames().sort()).toEqual([".hidden.md", "keep.md", "new.md"]);
expect([...state.files].sort()).toEqual([".hidden.md", "keep.md", "new.md"]);
expect(state.directories.get("")).toBe(entries);
expect(invalidatedDirectories.has("")).toBe(false);
});
it("retires an absent subtree while preserving newer descendant invalidations", async () => {
const state = inventory(
["gone/one.md", "gone/nested/two.md", "keep.md"],
[
["", [directory("gone"), file("keep.md")]],
["gone", [file("one.md"), directory("nested")]],
["gone/nested", [file("two.md")]],
]
);
const unrelatedFailure = new Error("unrelated scope remains unreadable");
state.readErrors.set("gone/nested", new Error("obsolete descendant failure"));
state.readErrors.set("other", unrelatedFailure);
const directoryGenerations = new Map([
["gone", 2],
["gone/nested", 3],
]);
const readDependencies: RefreshDirectoryDependencies = {
reader: { readDirectory: vi.fn<InventoryReader["readDirectory"]>().mockResolvedValue(null) },
basePath: "/vault",
reportDiagnostic: vi.fn(),
directoryGenerations,
getBoundary: () => Math.max(0, ...directoryGenerations.values()),
markDirectoryDirty: vi.fn(),
};
const changes = createRefreshChanges();
await refreshDirectory(readDependencies, state, "gone", changes, true, new Set());
expect(state.index.getFileNames()).toContain("gone/nested/two.md");
const invalidatedDirectories = new Map([
["gone", 5],
["gone/nested", 9],
]);
commitRefresh(
{ assertNotClosed: vi.fn(), onValidatedDirectories: vi.fn(), invalidatedDirectories, directoryGenerations },
state,
changes,
5
);
expect(state.index.getFileNames()).toEqual(["keep.md"]);
expect(state.directories.has("gone")).toBe(false);
expect(state.directories.has("gone/nested")).toBe(false);
expect(state.readErrors.has("gone/nested")).toBe(false);
expect(state.readErrors.get("other")).toBe(unrelatedFailure);
expect(invalidatedDirectories.get("gone/nested")).toBe(9);
expect(directoryGenerations.get("gone/nested")).toBe(3);
expect(directoryGenerations.has("gone")).toBe(false);
});
it("stages validated physical spellings while retiring prior Legacy exclusions", async () => {
const originalName = "Cafe\u0301.md";
const readDirectory = readerForListings(new Map([["", [file(originalName), file(".hidden.md")]]]));
const policy: PathComparisonSettings = { ...legacyPolicy, pathNormalizationVersion: 1 };
const listing = await readDirectory("", policy);
expect(listing?.kind).toBe("validated");
if (!listing) {
throw new Error("Expected a validated fixture listing");
}
const changes = createRefreshChanges();
const reportDiagnostic = vi.fn();
stageFileMembershipChanges({ reportDiagnostic }, "", [file("old.md"), file("C:excluded.md")], listing, changes);
expect([...changes.removedFiles]).toEqual(["old.md"]);
expect([...changes.addedFiles].sort()).toEqual([".hidden.md", originalName]);
expect(reportDiagnostic).not.toHaveBeenCalled();
});
it("diagnoses only newly excluded Legacy file names when staging a delta", () => {
const changes = createRefreshChanges();
const reportDiagnostic = vi.fn();
stageFileMembershipChanges(
{ reportDiagnostic },
"",
[file("keep.md"), file("old.md"), file("C:retained.md"), file("C:removed.md")],
legacyListing([file("keep.md"), file("new.md"), file("C:retained.md"), file("C:added.md")]),
changes
);
expect([...changes.removedFiles]).toEqual(["old.md"]);
expect([...changes.addedFiles]).toEqual(["new.md"]);
expect(reportDiagnostic).toHaveBeenCalledExactlyOnceWith(
"[CLIFileNameSource] Skipped invalid path 'C:added.md':",
expect.any(Error)
);
});
it("keeps subtree retirement idempotent and excludes hidden child directories", () => {
const state = inventory(
["gone/one.md", "gone/nested/two.md", "keep.md"],
[
["", [directory("gone"), file("keep.md")]],
["gone", [file("one.md"), directory("nested"), directory(".private")]],
["gone/nested", [file("two.md")]],
]
);
const changes = createRefreshChanges();
const visited = new Set<string>();
stageRemoveSubtree(state, "gone", changes, visited);
stageRemoveSubtree(state, "gone", changes, visited);
stageRemoveSubtree(state, "gone", changes, new Set());
expect(currentEntries(state, "gone", changes)).toEqual([]);
expect([...changes.removedFiles].sort()).toEqual(["gone/nested/two.md", "gone/one.md"]);
expect([...changes.removedDirectories].sort()).toEqual(["gone", "gone/nested"]);
expect(state.index.getFileNames().sort()).toEqual(["gone/nested/two.md", "gone/one.md", "keep.md"]);
});
it("keeps the published view and pending scope when an observation supersedes a read", async () => {
const state = inventory(["before.md"], [["", [file("before.md")]]]);
let finishRead!: (listing: DirectoryListing) => void;
const readDirectory = vi.fn<InventoryReader["readDirectory"]>(
() =>
new Promise((resolve) => {
finishRead = resolve;
})
);
const directoryGenerations = new Map([["", 1]]);
const invalidatedDirectories = new Map([["", 7]]);
const onValidatedDirectories = vi.fn();
const dependencies: ReconcilePendingDependencies = {
reader: { readDirectory },
basePath: "/vault",
reportDiagnostic: vi.fn(),
directoryGenerations,
getBoundary: () => Math.max(0, ...directoryGenerations.values()),
invalidatedDirectories,
markDirectoryDirty: vi.fn(),
assertNotClosed: vi.fn(),
onValidatedDirectories,
};
const pending = reconcilePending(dependencies, state, 7);
directoryGenerations.set("", 2);
finishRead(legacyListing([file("after.md")]));
await expect(pending).resolves.toEqual(new Set([""]));
expect(state.index.getFileNames()).toEqual(["before.md"]);
expect(state.directories.get("")).toEqual([file("before.md")]);
expect(invalidatedDirectories.get("")).toBe(7);
expect(onValidatedDirectories).not.toHaveBeenCalled();
});
it("clears prior error evidence before notifying the owner and then publishes new errors", () => {
const state = inventory([], [["", []]]);
state.readErrors.set("", new Error("previous failed read"));
const failure = new Error("new failed read");
const changes = createRefreshChanges();
changes.clearedErrors.add("");
changes.readErrors.set("", failure);
const onValidatedDirectories = vi.fn((_policy: PathComparisonSettings, scopes: ReadonlySet<string>) => {
expect(scopes.has("")).toBe(true);
expect(state.readErrors.has("")).toBe(false);
});
const dependencies: CommitRefreshDependencies = {
assertNotClosed: vi.fn(),
onValidatedDirectories,
invalidatedDirectories: new Map(),
directoryGenerations: new Map(),
};
commitRefresh(dependencies, state, changes, 0);
expect(onValidatedDirectories).toHaveBeenCalledOnce();
expect(state.readErrors.get("")).toBe(failure);
});
it("retains an observation whose generation is newer than the staged read", () => {
const state = inventory(["keep.md"], [["", [file("keep.md")]]]);
const changes = createRefreshChanges();
changes.checkedGenerations.set("", 1);
const invalidatedDirectories = new Map([["", 5]]);
const directoryGenerations = new Map([["", 2]]);
commitRefresh(
{
assertNotClosed: vi.fn(),
onValidatedDirectories: vi.fn(),
invalidatedDirectories,
directoryGenerations,
},
state,
changes,
5
);
// Its first sequence is captured, but the newer observation remains pending.
expect(invalidatedDirectories.get("")).toBe(5);
expect(directoryGenerations.get("")).toBe(2);
expect(state.index.getFileNames()).toEqual(["keep.md"]);
});
it.each([0, 1] as const)(
"retires a parent disappearing during target traversal without treating absence as a read error (version %i)",
async (version) => {
const policy: PathComparisonSettings = { ...legacyPolicy, pathNormalizationVersion: version };
const rootEntries = [directory("docs"), file("keep.md")];
const state = inventory(
["docs/nested/note.md", "keep.md"],
[
["", rootEntries],
["docs", [directory("nested")]],
["docs/nested", [file("note.md")]],
],
policy
);
// The root listing precedes deletion; the next physical read sees absence.
const readDirectory = readerForListings(new Map([["", rootEntries]]));
const directoryGenerations = new Map([
["", 1],
["docs", 2],
["docs/nested", 3],
]);
const dependencies: InventoryReconcilerDependencies = {
reader: {
readDirectory,
},
basePath: "/vault",
reportDiagnostic: vi.fn(),
invalidatedDirectories: new Map(),
directoryGenerations,
markDirectoryDirty: vi.fn(),
assertNotClosed: vi.fn(),
onValidatedDirectories: vi.fn(),
getBoundary: () => 3,
assertInventoryAvailable: vi.fn(),
};
const target = parseFilePath("docs/nested/note.md");
const generations = await revalidateTargetBranches(dependencies, state, target);
expect(readDirectory.mock.calls.map(([path]) => path)).toEqual(["", "docs"]);
expect(state.directories.has("docs")).toBe(false);
expect(state.directories.has("docs/nested")).toBe(false);
expect(state.index.getFileNames()).toEqual(["keep.md"]);
expect(state.index.findPathIdentityCandidates(target, policy)).toEqual([]);
expect(state.readErrors.size).toBe(0);
expect(dependencies.reportDiagnostic).not.toHaveBeenCalled();
expect(generationsUnchanged(directoryGenerations, generations)).toBe(true);
}
);
it("retains last known membership and the read failure before rejecting a target branch", async () => {
const policy: PathComparisonSettings = { pathNormalizationVersion: 1, handleFilenameCaseSensitive: true };
const state = inventory(["retained.md"], [["", [file("retained.md")]]], policy);
const failure = new Error("directory unreadable");
const dependencies: InventoryReconcilerDependencies = {
reader: {
readDirectory: vi.fn<InventoryReader["readDirectory"]>().mockRejectedValue(failure),
},
basePath: "/vault",
reportDiagnostic: vi.fn(),
invalidatedDirectories: new Map([["", 1]]),
directoryGenerations: new Map([["", 1]]),
markDirectoryDirty: vi.fn(),
assertNotClosed: vi.fn(),
onValidatedDirectories: vi.fn(),
getBoundary: () => 1,
assertInventoryAvailable: vi.fn(),
};
await expect(refreshSingleDirectory(dependencies, state, "")).rejects.toBe(failure);
expect(state.index.getFileNames()).toEqual(["retained.md"]);
expect(state.readErrors.get("")).toBe(failure);
});
it("validates every equivalent physical parent before returning target generations", async () => {
const policy: PathComparisonSettings = { pathNormalizationVersion: 0, handleFilenameCaseSensitive: false };
const state = inventory([], [["", []]], policy);
const listings = new Map<string, readonly DirectoryEntry[]>([
["", [directory("Docs"), directory("docs")]],
["Docs", [file("Note.md")]],
["docs", [file("note.md")]],
]);
const readDirectory = readerForListings(listings);
const invalidatedDirectories = new Map<string, number>();
const directoryGenerations = new Map([["", 1]]);
let boundary = 1;
const dependencies: InventoryReconcilerDependencies = {
reader: {
readDirectory,
},
basePath: "/vault",
reportDiagnostic: vi.fn(),
invalidatedDirectories,
directoryGenerations,
markDirectoryDirty: (path) => {
invalidatedDirectories.set(path, ++boundary);
directoryGenerations.set(path, (directoryGenerations.get(path) ?? 0) + 1);
},
assertNotClosed: vi.fn(),
onValidatedDirectories: vi.fn(),
getBoundary: () => boundary,
assertInventoryAvailable: vi.fn(),
};
const generations = await revalidateTargetBranches(dependencies, state, parseFilePath("docs/note.md"));
expect(readDirectory.mock.calls.map(([path]) => path)).toEqual(["", "Docs", "docs"]);
expect(state.index.findPathIdentityCandidates(parseFilePath("docs/note.md"), policy)).toEqual([
"Docs/Note.md",
"docs/note.md",
]);
expect(generationsUnchanged(directoryGenerations, generations)).toBe(true);
directoryGenerations.set("Docs", 2);
expect(generationsUnchanged(directoryGenerations, generations)).toBe(false);
});
it.each([
[0, ".private/secret.md", [""]],
[1, "visible/.private/secret.md", ["", "visible"]],
] as const)(
"keeps hidden subtrees outside current-path validation (version %i, path %s)",
async (version, target, expectedReads) => {
const policy: PathComparisonSettings = { ...legacyPolicy, pathNormalizationVersion: version };
const state = inventory([], [["", []]], policy);
const listings = new Map<string, readonly DirectoryEntry[]>([
["", [directory(".private"), directory("visible")]],
["visible", [directory(".private")]],
[".private", [file("secret.md")]],
["visible/.private", [file("secret.md")]],
]);
const readDirectory = readerForListings(listings);
const dependencies: InventoryReconcilerDependencies = {
reader: {
readDirectory,
},
basePath: "/vault",
reportDiagnostic: vi.fn(),
invalidatedDirectories: new Map(),
directoryGenerations: new Map(),
markDirectoryDirty: vi.fn(),
assertNotClosed: vi.fn(),
onValidatedDirectories: vi.fn(),
getBoundary: () => 0,
assertInventoryAvailable: vi.fn(),
};
await revalidateTargetBranches(dependencies, state, parseFilePath(target));
expect(readDirectory.mock.calls.map(([path]) => path)).toEqual(expectedReads);
expect(state.index.getFileNames()).toEqual([]);
expect(state.index.findPathIdentityCandidates(parseFilePath(target), policy)).toEqual([]);
}
);
it("rejects subtree retirement when a descendant changes during its parent read", async () => {
const state = inventory(
["gone/nested/old.md"],
[
["", [directory("gone")]],
["gone", [directory("nested")]],
["gone/nested", [file("old.md")]],
]
);
let sequence = 2;
const directoryGenerations = new Map([
["gone", 1],
["gone/nested", 2],
]);
const readDirectory = vi.fn<InventoryReader["readDirectory"]>(async () => {
// The parent snapshot misses a child recreated before this read
// completes. Its earlier pending sequence is deliberately retained.
directoryGenerations.set("gone/nested", ++sequence);
return legacyListing([]);
});
const dependencies = {
reader: { readDirectory },
basePath: "/vault",
reportDiagnostic: vi.fn(),
directoryGenerations,
getBoundary: () => sequence,
markDirectoryDirty: vi.fn(),
};
const changes = createRefreshChanges();
await refreshDirectory(dependencies, state, "gone", changes, true, new Set());
expect(generationsUnchanged(directoryGenerations, changes.checkedGenerations)).toBe(false);
expect(state.index.getFileNames()).toEqual(["gone/nested/old.md"]);
});
it("reconciles a recreated descendant instead of losing its older pending observation", async () => {
const state = inventory(
["gone/nested/old.md"],
[
["", [directory("gone")]],
["gone", [directory("nested")]],
["gone/nested", [file("old.md")]],
]
);
let sequence = 2;
const directoryGenerations = new Map([
["gone", 1],
["gone/nested", 2],
]);
const invalidatedDirectories = new Map([
["gone", 1],
["gone/nested", 1],
]);
const readDirectory = vi.fn<InventoryReader["readDirectory"]>(async (path) => {
if (path === "gone") {
directoryGenerations.set("gone/nested", ++sequence);
return legacyListing([]);
}
return legacyListing([file("new.md")]);
});
const dependencies: ReconcilePendingDependencies = {
reader: { readDirectory },
basePath: "/vault",
reportDiagnostic: vi.fn(),
directoryGenerations,
invalidatedDirectories,
getBoundary: () => sequence,
markDirectoryDirty: vi.fn(),
assertNotClosed: vi.fn(),
onValidatedDirectories: vi.fn(),
};
const unresolved = await reconcilePending(dependencies, state, 2);
expect(unresolved).toEqual(new Set(["gone"]));
expect(invalidatedDirectories.get("gone")).toBe(1);
expect(readDirectory.mock.calls.map(([path]) => path)).toEqual(["gone", "gone/nested"]);
expect(state.index.getFileNames()).toEqual(["gone/nested/new.md"]);
});
});
@@ -0,0 +1,151 @@
import type {
FilePathIndex,
FilePath,
PathComparisonSettings,
DirectoryEntry,
StorageEntry,
} from "@vrtmrz/livesync-commonlib/storage";
import type { Stats } from "@vrtmrz/livesync-commonlib/node";
import type { ChokidarOptions, FSWatcher } from "chokidar";
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
/**
* One physical listing under the owner's selected policy.
*
* Legacy entries retain raw names, including diagnosed exclusions needed for
* later policy validation. NFC listings retain the resolver's branded physical
* paths. The owner still checks generations before trusting the read's freshness.
*/
export type DirectoryListing =
| { readonly kind: "legacy"; readonly entries: readonly DirectoryEntry[] }
| { readonly kind: "validated"; readonly entries: readonly StorageEntry[] };
/**
* Private contracts shared by the CLI name-source modules.
*
* Directory strings use original spellings relative to one storage root; the
* empty string denotes that root. Validated entries retain physical names,
* while invalidations may include requested scopes awaiting validation. The
* source owns readiness and publication order.
*/
export type InventoryState = {
/** Candidate buckets derived from the published original file names. */
readonly index: FilePathIndex;
/** Published membership used to discard repeated add/remove observations. */
readonly files: Set<FilePath>;
/** Last accepted immediate-child listing of each covered directory, including the root. */
readonly directories: Map<string, readonly DirectoryEntry[]>;
/** Failed scope reads retain earlier membership, which NFC queries must not trust. */
readonly readErrors: Map<string, Error>;
/** Identity-validation failures per directory under the selected policy. */
aliasErrors: Map<string, Error>;
/** Policy used to validate listings and retained identity failures. */
policy: PathComparisonSettings;
};
/** A directory transition staged privately until every observed generation still matches. */
export type RefreshChanges = {
/** Replacement listings and removed coverage, applied before membership changes. */
readonly directoryUpdates: Map<string, readonly DirectoryEntry[]>;
readonly removedDirectories: Set<string>;
/** A path staged in both sets has unchanged membership when the transition commits. */
readonly removedFiles: Set<FilePath>;
readonly addedFiles: Set<FilePath>;
/** New failures replace old diagnostics only when this transition can be published. */
readonly readErrors: Map<string, Error>;
readonly aliasErrors: Map<string, Error>;
/** Scopes validated or removed by this transition, allowing old failures to clear. */
readonly clearedErrors: Set<string>;
/** Generations accepted for reads and retired scopes; a mismatch rejects the transition. */
readonly checkedGenerations: Map<string, number>;
};
/** A failed fresh scan blocks reuse under the same policy until its scope is validated again. */
export type FreshValidationFailure = {
readonly policy: PathComparisonSettings;
readonly directory: string;
readonly error: Error;
};
export type WatchEventType = "add" | "change" | "unlink" | "addDir" | "unlinkDir";
/** Eligible synchronisation notifications retained in arrival order before activation. */
export type QueuedWatchEvent = {
readonly type: WatchEventType;
readonly path: FilePath;
readonly stats?: Stats;
};
/** Host-selected root, sync-only exclusions, diagnostics, and the watcher construction port. */
export type CLIFileNameSourceOptions = {
readonly basePath: string;
/** Filters sync delivery after inventory observation; it does not narrow name coverage. */
readonly ignoreRules?: IgnoreRules;
readonly reportDiagnostic?: CliDiagnosticReporter;
/** Capture operation-time suppression when observing, before startup buffering changes timing. */
readonly suppressEvent?: (path: string) => boolean;
/** Tests can control readiness and event order without installing another native watcher. */
readonly watcherFactory?: (basePath: string, options: ChokidarOptions) => FSWatcher;
};
/** Storage reads and policy checks; callers decide when a candidate inventory can become ready. */
export interface InventoryReader {
enumerate(policy: PathComparisonSettings, retainFailure?: boolean): Promise<InventoryState>;
selectPolicy(state: InventoryState, policy: PathComparisonSettings): Promise<void>;
readDirectory(directory: string, policy: PathComparisonSettings): Promise<DirectoryListing | null>;
}
/** Staged reconciliation uses the source's live generation maps but never owns its lifecycle. */
export interface InventoryReconciler {
/** Reconcile captured scopes; return those invalidated again while being read. */
reconcilePending(state: InventoryState, boundary: number, expandNewSubtrees?: boolean): Promise<Set<string>>;
/** Follow physical parents under state.policy, already selected by the serialised owner. */
revalidateTargetBranches(state: InventoryState, target: FilePath): Promise<Map<string, number>>;
generationsUnchanged(generations: ReadonlyMap<string, number>): boolean;
}
/** Retain an existing error's identity and diagnostics when a host callback throws. */
export function asError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
export function joinRelative(directory: string, leaf: string): string {
return directory === "" ? leaf : directory + "/" + leaf;
}
export function childLeaf(parent: string, child: string): string {
return parent === "" ? child : child.slice(parent.length + 1);
}
/** Derive cache names for future policy checks while the caller retains the typed listing. */
export function getRawDirectoryEntries(directory: string, listing: DirectoryListing): readonly DirectoryEntry[] {
if (listing.kind === "legacy") {
return listing.entries;
}
return listing.entries.map((entry) => ({ name: childLeaf(directory, entry.path), kind: entry.kind }));
}
export function parentPath(value: string): string {
const separator = value.lastIndexOf("/");
return separator < 0 ? "" : value.slice(0, separator);
}
export function depth(value: string): number {
return value === "" ? 0 : value.split("/").length;
}
/** Compare identity policy values rather than callback/object identity. */
export function samePolicy(left: PathComparisonSettings, right: PathComparisonSettings): boolean {
return (
left.pathNormalizationVersion === right.pathNormalizationVersion &&
left.handleFilenameCaseSensitive === right.handleFilenameCaseSensitive
);
}
export function isSupportedPolicy(policy: PathComparisonSettings): boolean {
return (
(policy.pathNormalizationVersion === 0 || policy.pathNormalizationVersion === 1) &&
typeof policy.handleFilenameCaseSensitive === "boolean"
);
}
+642
View File
@@ -0,0 +1,642 @@
import {
parseFilePath,
type FilePath,
type FileNameLookup,
type FileNameReadOptions,
type PathComparisonSettings,
} from "@vrtmrz/livesync-commonlib/storage";
import { path, type UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import type { IStorageEventWatchHandlers } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
import { findInvalidatedDirectoryScopes } from "./CLIFileNameSource.paths";
import { createInventoryReader } from "./CLIFileNameSource.inventory";
import { createInventoryReconciler } from "./CLIFileNameSource.reconciliation";
import { CLIFileNameSourceWatch } from "./CLIFileNameSource.watch";
import {
asError,
parentPath,
samePolicy,
type CLIFileNameSourceOptions,
type FreshValidationFailure,
type InventoryReader,
type InventoryReconciler,
type InventoryState,
type WatchEventType,
} from "./CLIFileNameSource.state";
export type { CLIFileNameSourceOptions } from "./CLIFileNameSource.state";
/** Only ready owns a published inventory; failed and closed sources cannot serve names. */
type Lifecycle =
| { readonly kind: "uninitialised" }
| { readonly kind: "initialising" }
| { readonly kind: "ready"; readonly inventory: InventoryState }
| { readonly kind: "failed"; readonly error: Error }
| { readonly kind: "closed" };
const noDiagnostic: CliDiagnosticReporter = () => undefined;
/**
* Coordinates the CLI's watched name inventory without owning file metadata.
*
* The lifecycle and publication queue stay here. Sidecars own native observation,
* supported enumeration, and staged directory reconciliation. Name queries become
* ready before retained sync operations are restored; live sync delivery starts
* later, so a collision query never waits for its own event-manager activation.
*/
export class CLIFileNameSource implements FileNameLookup {
private lifecycle: Lifecycle = { kind: "uninitialised" };
// Retain the shared attempt so concurrent preparation cannot create a second watcher.
private preparePromise: Promise<void> | undefined;
private get state(): InventoryState | undefined {
return this.lifecycle.kind === "ready" ? this.lifecycle.inventory : undefined;
}
// A failed replacement scan leaves the previous inventory intact, but its
// known failing scope must validate again before queries can reuse that view.
private freshValidationFailure: FreshValidationFailure | undefined;
// Sequence bounds captured work; directory generations reject stale reads of
// individual scopes. Neither is an identity-map cache invalidation counter.
private sequence = 0;
// Both ends of host-owned I/O advance this revision, fencing fresh full scans.
private mutationRevision = 0;
// Earliest unhandled observation per scope: later notifications do not extend
// a caller's captured work boundary, but do advance that scope's generation.
private readonly invalidatedDirectories = new Map<string, number>();
private readonly directoryGenerations = new Map<string, number>();
// Local I/O may still be changing names after invalidation. Captured readers
// wait for these completion tokens before they validate the affected scopes.
private readonly pendingMutations = new Map<
symbol,
{ readonly sequence: number; readonly completion: Promise<void> }
>();
// Coalesce background passes. A request during a pass permits one later pass;
// unresolved coverage alone does not cause a busy retry loop.
private reconciliationScheduled = false;
private reconciliationRequested = false;
// Only inventory operations enter this queue. Awaiting sync callbacks here
// would deadlock when their collision checks query the same source again.
private operationTail: Promise<void> = Promise.resolve();
private readonly basePath: string;
private readonly reportDiagnostic: CliDiagnosticReporter;
private readonly reader: InventoryReader;
private readonly reconciler: InventoryReconciler;
private readonly watch: CLIFileNameSourceWatch;
/**
* Wire the same storage boundary used by file I/O; constructors perform no I/O.
*
* @param storage - Host storage with existing path and symlink safeguards.
* @param getPolicy - Active settings, resolved after configuration has settled.
* @param options - Root, delivery exclusions, diagnostic, and watcher ports.
*/
constructor(
private readonly storage: UnicodeResolvingNodeStorageAdapter,
private readonly getPolicy: () => PathComparisonSettings,
options: CLIFileNameSourceOptions
) {
this.basePath = path.resolve(options.basePath);
this.reportDiagnostic = options.reportDiagnostic ?? noDiagnostic;
this.reader = createInventoryReader({
storage: this.storage,
basePath: this.basePath,
reportDiagnostic: this.reportDiagnostic,
markDirectoryDirty: (directory) => this.markDirectoryDirty(directory),
recordFreshFailure: (state, policy, error) => this.recordFreshFailure(state, policy, error),
});
this.reconciler = createInventoryReconciler({
reader: this.reader,
basePath: this.basePath,
reportDiagnostic: this.reportDiagnostic,
invalidatedDirectories: this.invalidatedDirectories,
directoryGenerations: this.directoryGenerations,
getBoundary: () => this.sequence,
markDirectoryDirty: (directory) => this.markDirectoryDirty(directory),
assertNotClosed: () => this.assertNotClosed(),
assertInventoryAvailable: (state, policy) => this.assertNfcInventoryAvailable(state, policy),
onValidatedDirectories: (policy, directories) => {
if (
this.freshValidationFailure &&
samePolicy(this.freshValidationFailure.policy, policy) &&
directories.has(this.freshValidationFailure.directory)
) {
this.freshValidationFailure = undefined;
}
},
});
this.watch = new CLIFileNameSourceWatch({
basePath: this.basePath,
options,
reportDiagnostic: this.reportDiagnostic,
canObserve: () => this.lifecycle.kind !== "closed" && this.lifecycle.kind !== "failed",
canDeliver: () => this.lifecycle.kind === "ready",
onObservation: (type, filePath) => this.observeChange(type, filePath),
onInvalidPath: () => this.markPathsDirty([""]),
onFatalError: (error) => {
this.lifecycle = { kind: "failed", error };
},
});
}
/**
* Start observation and prepare names once, without activating sync delivery.
*
* Concurrent calls share preparation. The host calls this after settings are
* final and before replaying retained operations which can query names.
*/
prepare(): Promise<void> {
if (this.lifecycle.kind === "closed") {
return Promise.reject(new Error("The CLI file-name source is closed"));
}
if (this.lifecycle.kind === "failed") {
return Promise.reject(this.lifecycle.error);
}
if (this.lifecycle.kind === "ready") {
return Promise.resolve();
}
if (this.preparePromise) {
return this.preparePromise;
}
this.lifecycle = { kind: "initialising" };
this.preparePromise = this.prepareInternal();
return this.preparePromise;
}
/**
* Read names through one captured observation boundary, or perform a fresh scan.
*
* Later events remain pending. If a captured scope cannot be published safely,
* the request fails instead of extending its boundary until the Vault is quiet.
*/
async getFileNames(options: FileNameReadOptions = {}): Promise<FilePath[]> {
await this.ensureReady();
// Reconstruction preflight needs a new listing authority read. Ordinary
// calls settle only observations captured below and keep watched semantics.
if (options.fresh) {
return await this.getFreshFileNames();
}
const boundary = this.sequence;
await this.waitForMutations(boundary);
return await this.withOperationLock(async () => {
if (this.hasPendingMutationThrough(boundary)) {
throw new Error("A local mutation did not settle at the captured file-name boundary");
}
const state = this.requireState();
const policy = this.getPolicy();
await this.reader.selectPolicy(state, policy);
const unresolved = await this.reconciler.reconcilePending(state, boundary);
// A captured scope changed during validation or still needs work.
// Reject this view instead of silently presenting incomplete coverage.
if (unresolved.size > 0 || this.hasInvalidationThrough(boundary)) {
throw new Error("File-name observations changed while reconciling the captured boundary");
}
this.assertNfcInventoryAvailable(state, policy);
this.scheduleReconciliation();
return state.index.getFileNames();
});
}
/**
* Validate the requested physical branches before selecting identity candidates.
*
* Local mutations can require another attempt. Unrelated pending subtrees are
* reconciled separately; a target query does not recursively scan the Vault.
*/
async findPathIdentityCandidates(
pathValue: FilePath,
policy: PathComparisonSettings
): Promise<readonly FilePath[]> {
await this.ensureReady();
const target = parseFilePath(pathValue);
while (true) {
const mutationBoundary = this.sequence;
await this.waitForMutations(mutationBoundary);
const result = await this.withOperationLock(async () => {
if (this.pendingMutations.size > 0) {
return undefined;
}
const state = this.requireState();
// The source selects the policy before reconciliation consumes it
// from state. A new policy can expose failures in cached spellings;
// settle those scopes before choosing candidates for this path.
const policyChanged = !samePolicy(state.policy, policy);
await this.reader.selectPolicy(state, policy);
if (policyChanged) {
const boundary = this.sequence;
const unresolved = await this.reconciler.reconcilePending(state, boundary, false);
if (unresolved.size > 0) {
return undefined;
}
}
if (this.pendingMutations.size > 0) {
return undefined;
}
this.assertNfcInventoryAvailable(state, policy);
const generations = await this.reconciler.revalidateTargetBranches(state, target);
// This final cross-branch fence also catches an earlier parent
// changing while a later parent is read. Retry instead of returning
// candidates assembled from different observation generations.
if (!this.reconciler.generationsUnchanged(generations)) {
return undefined;
}
this.assertNfcInventoryAvailable(state, policy);
return state.index.findPathIdentityCandidates(target, policy);
});
if (result !== undefined) {
this.scheduleReconciliation();
return result;
}
}
}
/**
* Mark original paths before host-owned I/O and return its completion callback.
*
* The storage adapter calls the idempotent callback in finally, including on
* partial I/O failure. Bookkeeping failure blocks name queries while preserving
* the filesystem operation's own success or failure result.
*/
invalidate(paths: readonly string[]): () => void {
const token = Symbol("storage-mutation");
let settle!: () => void;
const pending = new Promise<void>((resolve) => {
settle = resolve;
});
this.mutationRevision++;
try {
this.markPathsDirty(paths);
} catch (error) {
this.markInventoryUnavailable(error);
}
this.pendingMutations.set(token, { sequence: this.sequence, completion: pending });
let completed = false;
return () => {
if (completed) {
return;
}
completed = true;
this.mutationRevision++;
try {
this.markPathsDirty(paths);
} catch (error) {
this.markInventoryUnavailable(error);
} finally {
this.pendingMutations.delete(token);
settle();
this.scheduleReconciliation();
}
};
}
/** Hand off observed startup events only after retained sync operations have been restored. */
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
await this.ensureReady();
this.watch.beginDelivery(handlers);
}
/** Freeze sync admission while keeping inventory observation available for shutdown work. */
stopDelivery(): void {
this.watch.stopDelivery();
}
/** Retire observation before waiting for inventory work, preventing late publication. */
async close(): Promise<void> {
if (this.lifecycle.kind === "closed") {
return;
}
this.lifecycle = { kind: "closed" };
this.watch.stopDelivery();
await this.watch.close();
await this.operationTail;
if (this.preparePromise) {
await this.preparePromise.catch(() => undefined);
}
}
private async prepareInternal(): Promise<void> {
try {
// Observe first: changes during seeding must reach both name invalidation
// and the finite startup delivery buffer.
await this.watch.startObservation();
this.assertNotClosed();
const mutationBoundary = this.sequence;
await this.waitForMutations(mutationBoundary);
const policy = this.getPolicy();
const candidate = await this.reader.enumerate(policy);
const boundary = this.sequence;
await this.reconciler.reconcilePending(candidate, boundary);
const finalPolicy = this.getPolicy();
await this.reader.selectPolicy(candidate, finalPolicy);
await this.reconciler.reconcilePending(candidate, this.sequence);
this.assertNfcInventoryAvailable(candidate, finalPolicy);
this.assertNotClosed();
this.lifecycle = { kind: "ready", inventory: candidate };
this.scheduleReconciliation();
} catch (error) {
const failure = asError(error);
if (this.lifecycle.kind !== "closed") {
this.lifecycle = { kind: "failed", error: failure };
}
await this.watch.close().catch(() => undefined);
throw failure;
}
}
/** This synchronous port runs before the watcher applies any sync-only exclusion. */
private observeChange(type: WatchEventType, filePath: FilePath): void {
this.markPathsDirty([filePath]);
if (type === "addDir" || type === "unlinkDir") {
this.markDirectoryDirty(String(filePath));
}
this.scheduleReconciliation();
}
private async ensureReady(): Promise<void> {
if (this.lifecycle.kind === "ready") {
return;
}
if (this.lifecycle.kind === "initialising" && this.preparePromise) {
await this.preparePromise;
return;
}
if (this.lifecycle.kind === "failed") {
throw this.lifecycle.error;
}
if (this.lifecycle.kind === "closed") {
throw new Error("The CLI file-name source is closed");
}
throw new Error("The CLI file-name source has not been prepared");
}
private requireState(): InventoryState {
if (!this.state || this.lifecycle.kind !== "ready") {
if (this.lifecycle.kind === "failed") {
throw this.lifecycle.error;
}
throw new Error("The CLI file-name inventory is unavailable");
}
return this.state;
}
private assertNotClosed(): void {
if (this.lifecycle.kind === "closed") {
throw new Error("The CLI file-name source is closed");
}
}
private async waitForMutations(boundary = Number.POSITIVE_INFINITY): Promise<void> {
while (true) {
const pending = Array.from(this.pendingMutations.values())
.filter((mutation) => mutation.sequence <= boundary)
.map((mutation) => mutation.completion);
if (pending.length === 0) {
return;
}
await Promise.all(pending);
}
}
private hasPendingMutationThrough(boundary: number): boolean {
return Array.from(this.pendingMutations.values()).some((mutation) => mutation.sequence <= boundary);
}
/** Serialise inventory work and always release the queue after a failed read. */
private async withOperationLock<T>(operation: () => Promise<T>): Promise<T> {
const previous = this.operationTail;
let release!: () => void;
this.operationTail = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
try {
return await operation();
} finally {
release();
}
}
/** Build privately, fence overlapping local I/O, and replace only a validated full view. */
private async getFreshFileNames(): Promise<FilePath[]> {
const boundary = this.sequence;
await this.waitForMutations(boundary);
const startRevision = this.mutationRevision;
return await this.withOperationLock(async () => {
if (this.hasPendingMutationThrough(boundary)) {
throw new Error("A local mutation did not settle at the captured fresh file-name boundary");
}
this.assertNotClosed();
// Waiting for the queue may have overlapped a completed local write.
// Its changed revision prevents this fresh scan from replacing newer work.
this.assertFreshReadUnchanged(startRevision);
// Candidate reconciliation can acknowledge shared observations before
// publication. Retain their earliest boundary until the candidate commits,
// so a later rejection cannot hide work from the still-published inventory.
const retainedObservations = new Map<string, number>();
this.retainPendingObservations(retainedObservations);
try {
const policy = this.getPolicy();
const candidate = await this.reader.enumerate(policy, true);
this.assertFreshReadUnchanged(startRevision);
const scanBoundary = this.sequence;
this.retainPendingObservations(retainedObservations);
const unresolved = await this.reconciler.reconcilePending(candidate, scanBoundary);
// Host-owned I/O at either end of enumeration invalidates this candidate,
// even if delayed watcher notifications have not advanced any scope yet.
this.assertFreshReadUnchanged(startRevision);
if (unresolved.size > 0 || this.hasInvalidationThrough(scanBoundary)) {
throw new Error("File-name observations changed during fresh enumeration");
}
const finalPolicy = this.getPolicy();
await this.reader.selectPolicy(candidate, finalPolicy);
const finalBoundary = this.sequence;
this.retainPendingObservations(retainedObservations);
const finalUnresolved = await this.reconciler.reconcilePending(candidate, finalBoundary);
// Policy selection can await directory reads too. Apply the same
// publication fences after its final pass, before exposing names.
this.assertFreshReadUnchanged(startRevision);
if (finalUnresolved.size > 0 || this.hasInvalidationThrough(finalBoundary)) {
throw new Error("File-name observations changed during fresh enumeration");
}
this.freshValidationFailure = undefined;
try {
this.assertNfcInventoryAvailable(candidate, finalPolicy);
} catch (error) {
this.recordFreshFailure(candidate, finalPolicy, error);
throw error;
}
this.assertNotClosed();
this.lifecycle = { kind: "ready", inventory: candidate };
return candidate.index.getFileNames();
} catch (error) {
this.restorePendingObservations(retainedObservations);
throw error;
}
});
}
/** Host I/O which starts or completes during a fresh read prevents publication. */
private assertFreshReadUnchanged(startRevision: number): void {
if (this.mutationRevision !== startRevision || this.pendingMutations.size > 0) {
throw new Error("A local storage mutation overlapped fresh file-name enumeration");
}
}
/** Remember each scope's first pending sequence before a private reconciliation pass. */
private retainPendingObservations(retained: Map<string, number>): void {
for (const [directory, sequence] of this.invalidatedDirectories) {
if (!retained.has(directory)) {
retained.set(directory, sequence);
}
}
}
/** A rejected candidate must leave its acknowledged work pending for the published view. */
private restorePendingObservations(retained: ReadonlyMap<string, number>): void {
for (const [directory, sequence] of retained) {
const pendingSequence = this.invalidatedDirectories.get(directory);
if (pendingSequence === undefined || sequence < pendingSequence) {
this.markDirectoryDirty(directory, sequence);
}
}
}
/** Run one captured background pass; only newly requested work schedules another pass. */
private scheduleReconciliation(): void {
if (this.reconciliationScheduled) {
this.reconciliationRequested = true;
return;
}
if (this.lifecycle.kind !== "ready" || this.invalidatedDirectories.size === 0) {
return;
}
this.reconciliationScheduled = true;
const boundary = this.sequence;
void Promise.resolve()
.then(async () => {
await this.waitForMutations(boundary);
await this.withOperationLock(async () => {
if (this.lifecycle.kind !== "ready" || this.pendingMutations.size > 0) {
return;
}
const state = this.requireState();
await this.reconciler.reconcilePending(state, boundary);
});
})
.catch((error: unknown) => {
this.reportDiagnostic("[CLIFileNameSource] Background name reconciliation failed:", error);
})
.finally(() => {
this.reconciliationScheduled = false;
const requested = this.reconciliationRequested;
this.reconciliationRequested = false;
if (requested && this.lifecycle.kind === "ready" && this.invalidatedDirectories.size > 0) {
this.scheduleReconciliation();
}
});
}
/** Known failed coverage stays visible until its scope and policy validate again. */
private assertNfcInventoryAvailable(state: InventoryState, policy: PathComparisonSettings): void {
// A failed fresh scan is stronger evidence than the previous successful view.
if (this.freshValidationFailure && samePolicy(this.freshValidationFailure.policy, policy)) {
throw this.freshValidationFailure.error;
}
const rootError = state.readErrors.get("");
if (rootError) {
throw rootError;
}
// Legacy mode tolerates unreadable non-root scopes. NFC identity checks
// require all known scopes to be unambiguous and readable, even off-path.
if (policy.pathNormalizationVersion !== 1) {
return;
}
const error = state.aliasErrors.values().next().value ?? state.readErrors.values().next().value;
if (error) {
throw error;
}
}
private hasInvalidationThrough(boundary: number): boolean {
return Array.from(this.invalidatedDirectories.values()).some((sequence) => sequence <= boundary);
}
/** Keep the earliest pending sequence, while advancing every affected directory's generation. */
private markPathsDirty(paths: readonly string[]): void {
const sequence = ++this.sequence;
const directories = new Set<string>();
for (const rawPath of paths) {
const trimmed = rawPath.replace(/\/+$/u, "");
let parsed: FilePath;
try {
parsed = parseFilePath(trimmed);
} catch {
directories.add("");
continue;
}
const value = String(parsed);
const state = this.state;
const parent = parentPath(value);
if (value !== "" && state?.directories.has(value)) {
directories.add(value);
}
let current = parent;
while (current !== "" && !state?.directories.has(current)) {
directories.add(current);
current = parentPath(current);
}
directories.add(current);
}
const state = this.state;
if (state) {
// I/O accepts logical NFC spellings, while coverage and generations
// use physical names. Fence known equivalent parents synchronously,
// before an in-flight read could publish their old membership.
const policy = this.getPolicy();
for (const directory of [...directories]) {
for (const physicalDirectory of findInvalidatedDirectoryScopes(state.directories, directory, policy)) {
directories.add(physicalDirectory);
}
}
}
for (const directory of directories) {
this.markDirectoryDirty(directory, sequence);
}
}
private markInventoryUnavailable(error: unknown): void {
const failure = asError(error);
if (this.lifecycle.kind !== "closed") {
this.lifecycle = { kind: "failed", error: failure };
}
this.reportDiagnostic(
"[CLIFileNameSource] Inventory invalidation failed; name queries are unavailable:",
failure
);
}
private markDirectoryDirty(directory: string, sequence = this.sequence + 1): void {
// Keep the latest observation comparable with a read boundary, while
// the pending entry retains its earliest unhandled observation.
const generation = ++this.sequence;
const pendingSequence = this.invalidatedDirectories.get(directory);
// Failed private candidates can restore an earlier observation than a
// notification received after that candidate acknowledged the scope.
if (pendingSequence === undefined || sequence < pendingSequence) {
this.invalidatedDirectories.set(directory, sequence);
}
this.directoryGenerations.set(directory, generation);
}
/** Record a failed fresh scan without replacing the last validated inventory. */
private recordFreshFailure(state: InventoryState, policy: PathComparisonSettings, error: unknown): void {
const directory = state.aliasErrors.keys().next().value ?? state.readErrors.keys().next().value ?? "";
this.freshValidationFailure = { policy: { ...policy }, directory, error: asError(error) };
this.reportDiagnostic("[CLIFileNameSource] Fresh inventory validation failed:", error);
}
}
@@ -0,0 +1,889 @@
import { EventEmitter } from "node:events";
import { fs, os, path, UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import {
FilePathIndex,
parseFilePath,
type PathComparisonSettings,
type StoragePath,
} from "@vrtmrz/livesync-commonlib/storage";
import type { ChokidarOptions, FSWatcher } from "chokidar";
import type { IStorageEventWatchHandlers } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CLIFileNameSource } from "./CLIFileNameSource";
import { ObservedNodeStorageAdapter } from "./ObservedNodeStorageAdapter";
type TestWatcher = {
readonly eventEmitter: EventEmitter;
readonly watcher: FSWatcher;
readonly close: ReturnType<typeof vi.fn>;
};
const roots: string[] = [];
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((accept) => {
resolve = accept;
});
return { promise, resolve };
}
function makeWatcher(autoReady = true): TestWatcher {
const eventEmitter = new EventEmitter();
const close = vi.fn(async () => undefined);
const watcher = Object.assign(eventEmitter, { close }) as unknown as FSWatcher;
if (autoReady) {
queueMicrotask(() => eventEmitter.emit("ready"));
}
return { eventEmitter, watcher, close };
}
function makeRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-name-source-"));
roots.push(root);
return root;
}
function policy(version = 0, caseSensitive = true): PathComparisonSettings {
return {
pathNormalizationVersion: version as 0 | 1,
handleFilenameCaseSensitive: caseSensitive,
};
}
function makeSource(
root: string,
getPolicy: () => PathComparisonSettings,
watcher: TestWatcher,
storage: UnicodeResolvingNodeStorageAdapter = new UnicodeResolvingNodeStorageAdapter(
root,
() => getPolicy().pathNormalizationVersion,
() => !getPolicy().handleFilenameCaseSensitive
)
): CLIFileNameSource {
const watcherFactory = (_basePath: string, _options: ChokidarOptions): FSWatcher => watcher.watcher;
return new CLIFileNameSource(storage, getPolicy, { basePath: root, watcherFactory });
}
async function prepare(source: CLIFileNameSource, watcher: TestWatcher): Promise<void> {
const preparation = source.prepare();
await Promise.resolve();
watcher.eventEmitter.emit("ready");
await preparation;
}
function emit(watcher: TestWatcher, type: string, root: string, relativePath: string): void {
watcher.eventEmitter.emit(type, path.join(root, relativePath));
}
async function closeAndRemove(source: CLIFileNameSource, root: string): Promise<void> {
await source.close();
fs.rmSync(root, { recursive: true, force: true });
}
afterEach(() => {
for (const root of roots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
describe("CLIFileNameSource", () => {
it("updates one changed directory without another full inventory or index replacement", async () => {
const root = makeRoot();
fs.mkdirSync(path.join(root, "sub"), { recursive: true });
fs.writeFileSync(path.join(root, "sub", "note.md"), "initial");
const watcher = makeWatcher();
let currentPolicy = policy();
const storage = new UnicodeResolvingNodeStorageAdapter(root);
const readDirectory = vi.spyOn(storage, "readDirectory");
const replaceAll = vi.spyOn(FilePathIndex.prototype, "replaceAll");
const source = makeSource(root, () => currentPolicy, watcher, storage);
try {
await prepare(source, watcher);
expect(replaceAll).toHaveBeenCalledTimes(1);
const initialReads = readDirectory.mock.calls.length;
emit(watcher, "change", root, "sub/note.md");
expect(await source.getFileNames()).toEqual([parseFilePath("sub/note.md")]);
expect(readDirectory.mock.calls.length - initialReads).toBe(1);
expect(replaceAll).toHaveBeenCalledTimes(1);
} finally {
await closeAndRemove(source, root);
}
});
it("finds an NFC alias created before its watcher notification", async () => {
const root = makeRoot();
let currentPolicy = policy();
const watcher = makeWatcher();
const source = makeSource(root, () => currentPolicy, watcher);
const nfc = "caf\u00e9.md";
const nfd = "cafe\u0301.md";
try {
await prepare(source, watcher);
fs.writeFileSync(path.join(root, nfc), "one");
fs.writeFileSync(path.join(root, nfd), "two");
await expect(source.findPathIdentityCandidates(parseFilePath(nfc), policy(1, true))).rejects.toThrow(
"Ambiguous path after Unicode normalisation"
);
} finally {
await closeAndRemove(source, root);
}
});
it("retains a failed fresh NFC validation until a fresh scan repairs the unrelated scope", async () => {
const root = makeRoot();
fs.mkdirSync(path.join(root, "unrelated"), { recursive: true });
fs.writeFileSync(path.join(root, "safe.md"), "safe");
const currentPolicy = policy(1, true);
const watcher = makeWatcher();
const source = makeSource(root, () => currentPolicy, watcher);
const nfc = "café.md";
const nfd = "café.md";
try {
await prepare(source, watcher);
fs.writeFileSync(path.join(root, "unrelated", nfc), "one");
fs.writeFileSync(path.join(root, "unrelated", nfd), "two");
await expect(source.getFileNames({ fresh: true })).rejects.toThrow(/Ambiguous/);
await expect(source.findPathIdentityCandidates(parseFilePath("safe.md"), currentPolicy)).rejects.toThrow(
/Ambiguous/
);
fs.rmSync(path.join(root, "unrelated", nfd));
expect(fs.existsSync(path.join(root, "unrelated", nfd))).toBe(false);
expect(fs.readdirSync(path.join(root, "unrelated"))).toEqual([nfc]);
const internalStorage = (source as unknown as { storage: UnicodeResolvingNodeStorageAdapter }).storage;
await expect(internalStorage.readDirectory(parseFilePath("unrelated") as StoragePath)).resolves.toEqual([
{ name: nfc, kind: "file" },
]);
await expect(source.getFileNames({ fresh: true })).resolves.toEqual(
expect.arrayContaining([parseFilePath("safe.md"), parseFilePath("unrelated/" + nfc)])
);
await expect(source.findPathIdentityCandidates(parseFilePath("safe.md"), currentPolicy)).resolves.toEqual([
parseFilePath("safe.md"),
]);
} finally {
await closeAndRemove(source, root);
}
});
it("collects equivalent parent branches and every matching file in Legacy case-insensitive mode", async () => {
const root = makeRoot();
fs.mkdirSync(path.join(root, "Docs"), { recursive: true });
fs.mkdirSync(path.join(root, "docs"), { recursive: true });
fs.writeFileSync(path.join(root, "Docs", "Note.md"), "one");
fs.writeFileSync(path.join(root, "docs", "note.md"), "two");
const currentPolicy = policy(0, false);
const watcher = makeWatcher();
const source = makeSource(root, () => currentPolicy, watcher);
try {
await prepare(source, watcher);
await expect(
source.findPathIdentityCandidates(parseFilePath("docs/note.md"), currentPolicy)
).resolves.toEqual([parseFilePath("Docs/Note.md"), parseFilePath("docs/note.md")]);
} finally {
await closeAndRemove(source, root);
}
});
it("blocks all NFC lookups on a known unrelated alias and clears it after that directory is repaired", async () => {
const root = makeRoot();
fs.mkdirSync(path.join(root, "unrelated"), { recursive: true });
fs.writeFileSync(path.join(root, "safe.md"), "safe");
let currentPolicy = policy();
const watcher = makeWatcher();
const source = makeSource(root, () => currentPolicy, watcher);
const nfc = "caf\u00e9.md";
const nfd = "cafe\u0301.md";
try {
await prepare(source, watcher);
currentPolicy = policy(1, true);
fs.writeFileSync(path.join(root, "unrelated", nfc), "one");
fs.writeFileSync(path.join(root, "unrelated", nfd), "two");
emit(watcher, "add", root, "unrelated/" + nfc);
await expect(source.findPathIdentityCandidates(parseFilePath("safe.md"), currentPolicy)).rejects.toThrow(
/Ambiguous/
);
fs.rmSync(path.join(root, "unrelated", nfd));
emit(watcher, "unlink", root, "unrelated/" + nfd);
await expect(source.getFileNames()).resolves.toEqual([
parseFilePath("safe.md"),
parseFilePath("unrelated/" + nfc),
]);
} finally {
await closeAndRemove(source, root);
}
});
it("revalidates parent aliases when the active Unicode or case policy changes", async () => {
const root = makeRoot();
fs.mkdirSync(path.join(root, "Notes"));
fs.mkdirSync(path.join(root, "notes"));
fs.writeFileSync(path.join(root, "Notes/a.md"), "upper parent");
fs.writeFileSync(path.join(root, "notes/b.md"), "lower parent");
const watcher = makeWatcher();
let activePolicy = policy(0, false);
const source = makeSource(root, () => activePolicy, watcher);
try {
await prepare(source, watcher);
await expect(source.findPathIdentityCandidates(parseFilePath("Notes/a.md"), activePolicy)).resolves.toEqual(
["Notes/a.md"]
);
activePolicy = policy(1, false);
await expect(source.findPathIdentityCandidates(parseFilePath("Notes/a.md"), activePolicy)).rejects.toThrow(
/Ambiguous/
);
activePolicy = policy(1, true);
await expect(source.findPathIdentityCandidates(parseFilePath("Notes/a.md"), activePolicy)).resolves.toEqual(
["Notes/a.md"]
);
activePolicy = policy(0, true);
await expect(source.findPathIdentityCandidates(parseFilePath("notes/b.md"), activePolicy)).resolves.toEqual(
["notes/b.md"]
);
} finally {
await closeAndRemove(source, root);
}
});
it("settles preparation and closes the watcher when closed before ready", async () => {
const root = makeRoot();
const watcher = makeWatcher(false);
const source = makeSource(root, () => policy(), watcher);
const preparation = source.prepare();
const rejected = expect(preparation).rejects.toThrow("closed before watcher readiness");
await source.close();
await rejected;
await expect(source.prepare()).rejects.toThrow("closed");
expect(watcher.close).toHaveBeenCalledTimes(1);
fs.rmSync(root, { recursive: true, force: true });
});
it("preserves a synchronous watcher-close failure during failed preparation", async () => {
const root = makeRoot();
const watcher = makeWatcher(false);
const storage = new UnicodeResolvingNodeStorageAdapter(root);
const readFailure = new Error("initial directory read failed");
const closeFailure = new Error("synchronous watcher close failed");
vi.spyOn(storage, "readDirectory").mockRejectedValue(readFailure);
watcher.close.mockImplementation(() => {
throw closeFailure;
});
const source = makeSource(root, () => policy(), watcher, storage);
try {
const preparation = source.prepare();
const rejected = expect(preparation).rejects.toBe(closeFailure);
await Promise.resolve();
watcher.eventEmitter.emit("ready");
await rejected;
expect(watcher.close).toHaveBeenCalledTimes(1);
} finally {
await closeAndRemove(source, root);
}
});
it("buffers only observed startup events and hands them off in arrival order", async () => {
const root = makeRoot();
fs.writeFileSync(path.join(root, "seed.md"), "seed");
const watcher = makeWatcher();
const currentPolicy = policy();
const storage = new UnicodeResolvingNodeStorageAdapter(root);
const originalReadDirectory = storage.readDirectory.bind(storage);
const readDirectory = vi.spyOn(storage, "readDirectory");
let emitted = false;
readDirectory.mockImplementation(async (directory) => {
const entries = await originalReadDirectory(directory);
if (directory === "" && !emitted) {
emitted = true;
fs.writeFileSync(path.join(root, "during-startup.md"), "new");
emit(watcher, "add", root, "during-startup.md");
emit(watcher, "change", root, "during-startup.md");
fs.writeFileSync(path.join(root, ".hidden-during-startup.md"), "hidden");
emit(watcher, "add", root, ".hidden-during-startup.md");
}
return entries;
});
const source = makeSource(root, () => currentPolicy, watcher, storage);
const delivered: string[] = [];
const handlers = {
onCreate: vi.fn((file: { path: string }) => delivered.push("create:" + file.path)),
onChange: vi.fn((file: { path: string }) => delivered.push("change:" + file.path)),
onDelete: vi.fn(),
onRename: vi.fn(),
} as unknown as IStorageEventWatchHandlers;
try {
await prepare(source, watcher);
await expect(source.getFileNames()).resolves.toEqual(
expect.arrayContaining([parseFilePath("during-startup.md"), parseFilePath("seed.md")])
);
expect(delivered).toEqual([]);
const buffered = Reflect.get(Reflect.get(source, "watch"), "bufferedEvents") as unknown[];
expect(buffered).toHaveLength(2);
expect(await source.getFileNames()).toContain(".hidden-during-startup.md");
await source.beginWatch(handlers);
expect(delivered).toEqual(["create:during-startup.md", "change:during-startup.md"]);
expect(handlers.onCreate).toHaveBeenCalledTimes(1);
expect(handlers.onChange).toHaveBeenCalledTimes(1);
} finally {
await closeAndRemove(source, root);
}
});
it("keeps one finite name-list boundary when a watched directory changes during reconciliation", async () => {
const root = makeRoot();
fs.mkdirSync(path.join(root, "sub"), { recursive: true });
fs.writeFileSync(path.join(root, "sub", "note.md"), "initial");
const watcher = makeWatcher();
const currentPolicy = policy();
const storage = new UnicodeResolvingNodeStorageAdapter(root);
const source = makeSource(root, () => currentPolicy, watcher, storage);
const internal = source as unknown as {
markPathsDirty(paths: readonly string[]): void;
invalidatedDirectories: Map<string, number>;
};
try {
await prepare(source, watcher);
internal.markPathsDirty(["sub/note.md"]);
const originalReadDirectory = storage.readDirectory.bind(storage);
const readDirectory = vi.spyOn(storage, "readDirectory").mockImplementation(async (directory) => {
internal.markPathsDirty(["sub/note.md"]);
return await originalReadDirectory(directory);
});
await expect(source.getFileNames()).rejects.toThrow("captured boundary");
expect(readDirectory).toHaveBeenCalledTimes(1);
expect(internal.invalidatedDirectories.has("sub")).toBe(true);
} finally {
await closeAndRemove(source, root);
}
});
it("prunes directory generations after repeated folder creation and deletion", async () => {
const root = makeRoot();
const watcher = makeWatcher();
const source = makeSource(root, () => policy(), watcher);
const generations = (source as unknown as { directoryGenerations: Map<string, number> }).directoryGenerations;
const invalidations = (source as unknown as { invalidatedDirectories: Map<string, number> })
.invalidatedDirectories;
try {
await prepare(source, watcher);
for (let index = 0; index < 8; index++) {
const folder = "temporary-" + index;
fs.mkdirSync(path.join(root, folder));
fs.writeFileSync(path.join(root, folder, "note.md"), "content");
emit(watcher, "addDir", root, folder);
emit(watcher, "add", root, folder + "/note.md");
await source.getFileNames();
fs.rmSync(path.join(root, folder), { recursive: true, force: true });
emit(watcher, "unlinkDir", root, folder);
await source.getFileNames();
expect(Array.from(generations.keys()).every((directory) => directory === "")).toBe(true);
expect(invalidations.size).toBe(0);
}
} finally {
await closeAndRemove(source, root);
}
});
it("reconciles a partial write failure after always completing mutation invalidation", async () => {
const root = makeRoot();
fs.mkdirSync(path.join(root, "notes"), { recursive: true });
const currentPolicy = policy();
const watcher = makeWatcher();
let source: CLIFileNameSource;
const storage = new ObservedNodeStorageAdapter(
root,
() => currentPolicy.pathNormalizationVersion,
() => !currentPolicy.handleFilenameCaseSensitive,
(paths) => source.invalidate(paths)
);
source = makeSource(root, () => currentPolicy, watcher, storage);
const write = vi
.spyOn(UnicodeResolvingNodeStorageAdapter.prototype, "write")
.mockImplementation(async (relativePath: string) => {
fs.writeFileSync(path.join(root, relativePath), "partially written");
throw new Error("injected write failure after physical change");
});
try {
await prepare(source, watcher);
await expect(storage.write("notes/partial.md", "body")).rejects.toThrow(
"injected write failure after physical change"
);
await expect(source.getFileNames()).resolves.toEqual([parseFilePath("notes/partial.md")]);
} finally {
write.mockRestore();
await closeAndRemove(source, root);
}
});
it("retains direct mutation observations when a fresh scan is rejected", async () => {
const root = makeRoot();
fs.writeFileSync(path.join(root, "before.md"), "before");
const watcher = makeWatcher();
const storage = new UnicodeResolvingNodeStorageAdapter(root);
const source = makeSource(root, () => policy(), watcher, storage);
const started = deferred<void>();
const release = deferred<void>();
try {
await prepare(source, watcher);
const read = storage.readDirectory.bind(storage);
vi.spyOn(storage, "readDirectory").mockImplementationOnce(async (directory) => {
const entries = await read(directory);
started.resolve(undefined);
await release.promise;
return entries;
});
const fresh = source.getFileNames({ fresh: true });
const rejected = expect(fresh).rejects.toThrow("local storage mutation overlapped");
await started.promise;
const complete = source.invalidate(["added.md"]);
try {
fs.writeFileSync(path.join(root, "added.md"), "retained local write");
} finally {
complete();
}
release.resolve(undefined);
await rejected;
await expect(source.getFileNames()).resolves.toEqual(expect.arrayContaining(["before.md", "added.md"]));
expect(fs.readFileSync(path.join(root, "added.md"), "utf8")).toBe("retained local write");
} finally {
release.resolve(undefined);
await closeAndRemove(source, root);
}
});
it("retains watched scopes when unrelated local I/O rejects a fresh candidate", async () => {
const root = makeRoot();
for (const directory of ["a", "b"]) {
fs.mkdirSync(path.join(root, directory));
fs.writeFileSync(path.join(root, directory, "before.md"), "before");
}
const watcher = makeWatcher();
const storage = new UnicodeResolvingNodeStorageAdapter(root);
const source = makeSource(root, () => policy(), watcher, storage);
const started = deferred<void>();
const release = deferred<void>();
try {
await prepare(source, watcher);
const read = storage.readDirectory.bind(storage);
let observed = false;
let readsOfA = 0;
vi.spyOn(storage, "readDirectory").mockImplementation(async (directory) => {
const entries = await read(directory);
if (directory === "" && !observed) {
observed = true;
fs.writeFileSync(path.join(root, "a/observed.md"), "observed");
emit(watcher, "add", root, "a/observed.md");
}
if (directory === "a" && ++readsOfA === 2) {
started.resolve(undefined);
await release.promise;
}
return entries;
});
const fresh = source.getFileNames({ fresh: true });
const rejected = expect(fresh).rejects.toThrow("local storage mutation overlapped");
await started.promise;
const complete = source.invalidate(["b/local.md"]);
try {
fs.writeFileSync(path.join(root, "b/local.md"), "local");
} finally {
complete();
}
release.resolve(undefined);
await rejected;
await expect(source.getFileNames()).resolves.toEqual(
expect.arrayContaining(["a/observed.md", "b/local.md"])
);
} finally {
release.resolve(undefined);
await closeAndRemove(source, root);
}
});
it("retains successful scope observations when a fresh reconciliation is superseded", async () => {
const root = makeRoot();
for (const directory of ["a", "b"]) {
fs.mkdirSync(path.join(root, directory));
fs.writeFileSync(path.join(root, directory, "before.md"), "before");
}
const watcher = makeWatcher();
const storage = new UnicodeResolvingNodeStorageAdapter(root);
const source = makeSource(root, () => policy(), watcher, storage);
const started = deferred<void>();
const release = deferred<void>();
try {
await prepare(source, watcher);
const read = storage.readDirectory.bind(storage);
let observed = false;
let readsOfA = 0;
vi.spyOn(storage, "readDirectory").mockImplementation(async (directory) => {
const entries = await read(directory);
if (directory === "" && !observed) {
observed = true;
for (const child of ["a/observed.md", "b/observed.md"]) {
fs.writeFileSync(path.join(root, child), "observed");
emit(watcher, "add", root, child);
}
}
if (directory === "a" && ++readsOfA === 2) {
started.resolve(undefined);
await release.promise;
}
return entries;
});
const fresh = source.getFileNames({ fresh: true });
const rejected = expect(fresh).rejects.toThrow("observations changed during fresh enumeration");
await started.promise;
emit(watcher, "change", root, "a/observed.md");
release.resolve(undefined);
await rejected;
await expect(source.getFileNames()).resolves.toEqual(
expect.arrayContaining(["a/observed.md", "b/observed.md"])
);
} finally {
release.resolve(undefined);
await closeAndRemove(source, root);
}
});
it("rejects name reads before preparation and after close", async () => {
const root = makeRoot();
const watcher = makeWatcher(false);
const source = makeSource(root, () => policy(), watcher);
try {
await expect(source.getFileNames()).rejects.toThrow("has not been prepared");
await source.close();
await expect(source.getFileNames()).rejects.toThrow("closed");
} finally {
await closeAndRemove(source, root);
}
});
it("shares preparation and lets a waiting reader use the completed inventory", async () => {
const root = makeRoot();
fs.writeFileSync(path.join(root, "ready.md"), "ready");
const watcher = makeWatcher(false);
const storage = new UnicodeResolvingNodeStorageAdapter(root);
const read = vi.spyOn(storage, "readDirectory");
const source = makeSource(root, () => policy(), watcher, storage);
try {
const first = source.prepare();
expect(source.prepare()).toBe(first);
const names = source.getFileNames();
watcher.eventEmitter.emit("ready");
await first;
await source.prepare();
await expect(names).resolves.toEqual(["ready.md"]);
expect(read).toHaveBeenCalledOnce();
} finally {
await closeAndRemove(source, root);
}
});
it("keeps failed preparation unavailable for later reads and prepare calls", async () => {
const root = makeRoot();
const watcher = makeWatcher(false);
const storage = new UnicodeResolvingNodeStorageAdapter(root);
const failure = new Error("root listing failed");
vi.spyOn(storage, "readDirectory").mockRejectedValue(failure);
const source = makeSource(root, () => policy(), watcher, storage);
try {
const preparation = source.prepare();
const rejected = expect(preparation).rejects.toBe(failure);
watcher.eventEmitter.emit("ready");
await rejected;
await expect(source.prepare()).rejects.toBe(failure);
await expect(source.getFileNames()).rejects.toBe(failure);
expect(watcher.close).toHaveBeenCalledOnce();
} finally {
await closeAndRemove(source, root);
}
});
it("waits for host I/O completion and tolerates a duplicate completion", async () => {
const root = makeRoot();
const watcher = makeWatcher();
const source = makeSource(root, () => policy(), watcher);
let complete: () => void = () => undefined;
try {
await prepare(source, watcher);
complete = source.invalidate(["pending.md"]);
fs.writeFileSync(path.join(root, "pending.md"), "pending");
let settled = false;
const names = source.getFileNames().then((result) => {
settled = true;
return result;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(settled).toBe(false);
complete();
complete();
await expect(names).resolves.toEqual(["pending.md"]);
} finally {
complete();
await closeAndRemove(source, root);
}
});
it("rejects fresh and queued reads when close retires their inventory", async () => {
const root = makeRoot();
const watcher = makeWatcher();
const storage = new UnicodeResolvingNodeStorageAdapter(root);
const source = makeSource(root, () => policy(), watcher, storage);
const started = deferred<void>();
const release = deferred<void>();
try {
await prepare(source, watcher);
const read = storage.readDirectory.bind(storage);
vi.spyOn(storage, "readDirectory").mockImplementationOnce(async (directory) => {
const entries = await read(directory);
started.resolve(undefined);
await release.promise;
return entries;
});
const fresh = source.getFileNames({ fresh: true });
const rejectedFresh = expect(fresh).rejects.toThrow("closed");
await started.promise;
const queued = source.getFileNames();
const rejectedQueued = expect(queued).rejects.toThrow("inventory is unavailable");
await new Promise<void>((resolve) => setImmediate(resolve));
const closing = source.close();
release.resolve(undefined);
await rejectedFresh;
await rejectedQueued;
await closing;
await expect(source.getFileNames()).rejects.toThrow("closed");
} finally {
release.resolve(undefined);
await closeAndRemove(source, root);
}
});
it("restores observations through a reader's earlier captured boundary", async () => {
const root = makeRoot();
for (const directory of ["a", "b"]) {
fs.mkdirSync(path.join(root, directory));
fs.writeFileSync(path.join(root, directory, "before.md"), "before");
}
const watcher = makeWatcher();
const storage = new UnicodeResolvingNodeStorageAdapter(root);
const source = makeSource(root, () => policy(), watcher, storage);
const started = deferred<void>();
const release = deferred<void>();
try {
await prepare(source, watcher);
const read = storage.readDirectory.bind(storage);
let observed = false;
let readsOfB = 0;
vi.spyOn(storage, "readDirectory").mockImplementation(async (directory) => {
const entries = await read(directory);
if (directory === "" && !observed) {
observed = true;
for (const child of ["a/observed.md", "b/observed.md"]) {
fs.writeFileSync(path.join(root, child), "observed");
emit(watcher, "add", root, child);
}
}
if (directory === "b" && ++readsOfB === 2) {
started.resolve(undefined);
await release.promise;
}
return entries;
});
const fresh = source.getFileNames({ fresh: true });
const rejected = expect(fresh).rejects.toThrow("observations changed during fresh enumeration");
await started.promise;
const capturedRead = source.getFileNames();
// Let this request capture its boundary before the later native-event turn.
await new Promise<void>((resolve) => setImmediate(resolve));
emit(watcher, "change", root, "a/observed.md");
emit(watcher, "change", root, "b/observed.md");
release.resolve(undefined);
await rejected;
await expect(capturedRead).resolves.toEqual(expect.arrayContaining(["a/observed.md", "b/observed.md"]));
} finally {
release.resolve(undefined);
await closeAndRemove(source, root);
}
});
it("rejects an unavailable root and recovers after that scope is recreated", async () => {
const root = makeRoot();
fs.writeFileSync(path.join(root, "before.md"), "before");
const watcher = makeWatcher();
const source = makeSource(root, () => policy(0), watcher);
try {
await prepare(source, watcher);
const complete = source.invalidate([""]);
fs.rmSync(root, { recursive: true, force: true });
complete();
await expect(source.getFileNames()).rejects.toThrow("root is unavailable");
fs.mkdirSync(root);
fs.writeFileSync(path.join(root, "after.md"), "after");
emit(watcher, "add", root, "after.md");
await expect(source.getFileNames()).resolves.toEqual(["after.md"]);
} finally {
await closeAndRemove(source, root);
}
});
it("clears a known fresh failure only after the affected scope validates", async () => {
const root = makeRoot();
fs.mkdirSync(path.join(root, "blocked"));
fs.writeFileSync(path.join(root, "visible.md"), "visible");
const watcher = makeWatcher();
const storage = new UnicodeResolvingNodeStorageAdapter(root);
const selectedPolicy = policy(1);
const source = makeSource(root, () => selectedPolicy, watcher, storage);
try {
await prepare(source, watcher);
const read = storage.readDirectory.bind(storage);
const failure = new Error("blocked scope cannot be read");
const listing = vi.spyOn(storage, "readDirectory").mockImplementation((directory) => {
if (directory === "blocked") {
return Promise.reject(failure);
}
return read(directory);
});
await expect(source.getFileNames({ fresh: true })).rejects.toBe(failure);
await expect(source.findPathIdentityCandidates(parseFilePath("visible.md"), selectedPolicy)).rejects.toBe(
failure
);
listing.mockRestore();
fs.writeFileSync(path.join(root, "blocked/restored.md"), "restored");
emit(watcher, "add", root, "blocked/restored.md");
await expect(source.getFileNames()).resolves.toEqual(
expect.arrayContaining(["visible.md", "blocked/restored.md"])
);
await expect(
source.findPathIdentityCandidates(parseFilePath("visible.md"), selectedPolicy)
).resolves.toEqual(["visible.md"]);
} finally {
await closeAndRemove(source, root);
}
});
it("retries a target read superseded by a watched alias before choosing candidates", async () => {
const root = makeRoot();
fs.writeFileSync(path.join(root, "note.md"), "original");
const watcher = makeWatcher();
const storage = new UnicodeResolvingNodeStorageAdapter(root);
const selectedPolicy = policy(0, false);
const source = makeSource(root, () => selectedPolicy, watcher, storage);
const started = deferred<void>();
const release = deferred<void>();
try {
await prepare(source, watcher);
const read = storage.readDirectory.bind(storage);
let firstRootRead = true;
const listing = vi.spyOn(storage, "readDirectory").mockImplementation(async (directory) => {
const entries = await read(directory);
if (directory === "" && firstRootRead) {
firstRootRead = false;
started.resolve(undefined);
await release.promise;
}
return entries;
});
const candidates = source.findPathIdentityCandidates(parseFilePath("note.md"), selectedPolicy);
await started.promise;
fs.writeFileSync(path.join(root, "NOTE.MD"), "external alias");
emit(watcher, "add", root, "NOTE.MD");
release.resolve(undefined);
await expect(candidates).resolves.toEqual(["NOTE.MD", "note.md"]);
expect(listing.mock.calls.filter(([directory]) => directory === "").length).toBeGreaterThan(1);
expect(fs.readFileSync(path.join(root, "note.md"), "utf8")).toBe("original");
expect(fs.readFileSync(path.join(root, "NOTE.MD"), "utf8")).toBe("external alias");
} finally {
release.resolve(undefined);
await closeAndRemove(source, root);
}
});
it.each(["local mutation", "watched change"] as const)(
"rejects an overlapping %s during final-policy reconciliation",
async (change) => {
const root = makeRoot();
for (const directory of ["a", "b"]) {
fs.mkdirSync(path.join(root, directory));
fs.writeFileSync(path.join(root, directory, "before.md"), "before");
}
const watcher = makeWatcher();
const storage = new UnicodeResolvingNodeStorageAdapter(root);
let currentPolicy = policy(0);
const source = makeSource(root, () => currentPolicy, watcher, storage);
const legacyReadStarted = deferred<void>();
const releaseLegacyRead = deferred<void>();
const finalReadStarted = deferred<void>();
const releaseFinalRead = deferred<void>();
let complete: (() => void) | undefined;
try {
await prepare(source, watcher);
const read = storage.readDirectory.bind(storage);
let readsOfA = 0;
vi.spyOn(storage, "readDirectory").mockImplementation(async (directory) => {
if (directory === "a" && ++readsOfA === 1) {
legacyReadStarted.resolve(undefined);
await releaseLegacyRead.promise;
throw new Error("transient Legacy read failure");
}
const entries = await read(directory);
if (directory === "a" && readsOfA === 2) {
finalReadStarted.resolve(undefined);
await releaseFinalRead.promise;
}
return entries;
});
const fresh = source.getFileNames({ fresh: true });
const expectedError =
change === "local mutation"
? "local storage mutation overlapped"
: "observations changed during fresh enumeration";
const rejected = expect(fresh).rejects.toThrow(expectedError);
await legacyReadStarted.promise;
currentPolicy = policy(1);
releaseLegacyRead.resolve(undefined);
await finalReadStarted.promise;
if (change === "local mutation") {
complete = source.invalidate(["b/pending.md"]);
fs.writeFileSync(path.join(root, "b/pending.md"), "pending");
} else {
fs.writeFileSync(path.join(root, "a/observed.md"), "observed");
emit(watcher, "add", root, "a/observed.md");
}
releaseFinalRead.resolve(undefined);
await rejected;
} finally {
complete?.();
releaseLegacyRead.resolve(undefined);
releaseFinalRead.resolve(undefined);
await closeAndRemove(source, root);
}
}
);
});
@@ -0,0 +1,288 @@
import { watch as chokidarWatch, type ChokidarOptions, type FSWatcher } from "chokidar";
import { parseFilePath, type FilePath } from "@vrtmrz/livesync-commonlib/storage";
import { getNodeFileCreationTime, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
import type { IStorageEventWatchHandlers } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
import type { NodeFile, NodeFolder } from "@/apps/cli/adapters/NodeTypes";
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
import { isHiddenPath } from "@/apps/cli/cliPathFilter";
import {
asError,
type CLIFileNameSourceOptions,
type QueuedWatchEvent,
type WatchEventType,
} from "./CLIFileNameSource.state";
interface FileNameWatchDependencies {
readonly basePath: string;
readonly options: CLIFileNameSourceOptions;
readonly reportDiagnostic: CliDiagnosticReporter;
readonly canObserve: () => boolean;
readonly canDeliver: () => boolean;
readonly onObservation: (type: WatchEventType, path: FilePath) => void;
readonly onInvalidPath: () => void;
readonly onFatalError: (error: Error) => void;
}
const ignoredSyncSuffix = /(^|[/\\])[^/\\]*-livesync-v2([/\\]|$)/;
/**
* Owns the source's one native watcher and its synchronisation-delivery buffer.
*
* Observation begins before enumeration, whereas delivery starts after startup
* recovery. Inventory invalidation always runs before sync-only exclusions, so
* an ignored or rejected event cannot hide a name from collision checks.
*/
export class CLIFileNameSourceWatch {
// Acquire ownership before awaiting readiness, so close can release a watcher
// whose initial scan has not completed or whose ready event will never arrive.
private watcher: FSWatcher | undefined;
private cancelReadyWait: ((error: Error) => void) | undefined;
private closed = false;
// Only eligible startup notifications are retained; live events go straight
// to handlers after activation, without another long-lived delivery queue.
private readonly bufferedEvents: QueuedWatchEvent[] = [];
private handlers: IStorageEventWatchHandlers | undefined;
// Activation happens once after recovery. Stopping is terminal for delivery,
// while observation can remain open until accepted shutdown work has drained.
private deliveryActive = false;
private deliveryStopped = false;
private readonly basePath: string;
private readonly options: CLIFileNameSourceOptions;
private readonly reportDiagnostic: CliDiagnosticReporter;
constructor(private readonly dependencies: FileNameWatchDependencies) {
this.basePath = dependencies.basePath;
this.options = dependencies.options;
this.reportDiagnostic = dependencies.reportDiagnostic;
}
/** Acquire the watcher immediately so shutdown can cancel even its ready wait. */
async startObservation(): Promise<void> {
if (this.closed) {
throw new Error("The CLI file-name source is closed");
}
const watcher = (this.options.watcherFactory ?? chokidarWatch)(this.basePath, this.createWatcherOptions());
this.watcher = watcher;
this.attachWatcher(watcher);
await this.waitForWatcherReady(watcher);
}
/**
* Stop delivery and release observation; this method owns no inventory queue.
*
* Return the native close promise directly so synchronous close failures keep
* their original propagation through preparation cleanup.
*/
close(): Promise<void> {
if (this.closed) {
return Promise.resolve();
}
this.closed = true;
this.stopDelivery();
this.cancelReadyWait?.(new Error("The CLI file-name source closed before watcher readiness"));
this.cancelReadyWait = undefined;
const watcher = this.watcher;
this.watcher = undefined;
return watcher ? watcher.close() : Promise.resolve();
}
/** Hand off the finite startup buffer synchronously; downstream processing is not awaited. */
beginDelivery(handlers: IStorageEventWatchHandlers): void {
if (this.deliveryStopped) {
throw new Error("Synchronisation delivery has been stopped");
}
if (this.deliveryActive) {
throw new Error("Synchronisation delivery has already started");
}
this.handlers = handlers;
this.deliveryActive = true;
const buffered = this.bufferedEvents.splice(0);
for (const event of buffered) {
if (this.dependencies.canDeliver() && !this.deliveryStopped && event.path !== "") {
this.deliver(event);
}
}
}
/** Inventory observation continues while accepted sync work is flushed on shutdown. */
stopDelivery(): void {
this.deliveryStopped = true;
this.handlers = undefined;
this.bufferedEvents.length = 0;
}
/** Preserve content-stability timing while observing the complete supported listing scope. */
private createWatcherOptions(): ChokidarOptions {
return {
ignored: (candidatePath, stats) => this.isIgnoredDirectory(candidatePath, stats),
ignoreInitial: true,
persistent: true,
followSymlinks: false,
awaitWriteFinish: {
stabilityThreshold: 500,
pollInterval: 100,
},
};
}
/** Hidden directories are excluded from enumeration; hidden leaf files still need name coverage. */
private isIgnoredDirectory(candidatePath: string, stats?: Stats): boolean {
const relative = this.relativePath(candidatePath);
if (relative === null || relative === "") {
return false;
}
const segments = relative.split("/");
if (segments.slice(0, -1).some((segment) => segment.startsWith("."))) {
return true;
}
return Boolean(stats?.isDirectory() && segments[segments.length - 1]?.startsWith("."));
}
private attachWatcher(watcher: FSWatcher): void {
watcher.on("add", (filePath, stats) => this.observe("add", filePath, stats));
watcher.on("change", (filePath, stats) => this.observe("change", filePath, stats));
watcher.on("unlink", (filePath) => this.observe("unlink", filePath));
watcher.on("addDir", (dirPath) => this.observe("addDir", dirPath));
watcher.on("unlinkDir", (dirPath) => this.observe("unlinkDir", dirPath));
watcher.on("error", (error) => this.handleWatcherError(watcher, error));
}
private waitForWatcherReady(watcher: FSWatcher): Promise<void> {
return new Promise<void>((resolve, reject) => {
let settled = false;
const finish = (error?: Error): void => {
if (settled) {
return;
}
settled = true;
this.cancelReadyWait = undefined;
if (error) {
reject(error);
} else if (this.closed) {
reject(new Error("The CLI file-name source closed before watcher readiness"));
} else {
resolve();
}
};
this.cancelReadyWait = (error) => finish(error);
watcher.once("ready", () => finish());
});
}
/** Preserve the CLI's fatal-watcher policy: mark names unavailable before process exit. */
private handleWatcherError(watcher: FSWatcher, error: unknown): void {
const failure = asError(error);
this.dependencies.onFatalError(failure);
this.reportDiagnostic("[CLIFileNameSource] Fatal watcher error — file watching stopped:", failure);
this.reportDiagnostic("[CLIFileNameSource] Exiting for systemd restart.");
this.cancelReadyWait?.(failure);
this.cancelReadyWait = undefined;
if (this.watcher === watcher) {
this.watcher = undefined;
}
void watcher.close();
process.exit(1);
}
private observe(type: WatchEventType, absolutePath: string, stats?: Stats): void {
if (!this.dependencies.canObserve()) {
return;
}
const relative = this.relativePath(absolutePath);
if (relative === null || relative === "") {
return;
}
let filePath: FilePath;
try {
filePath = parseFilePath(relative);
} catch (error) {
this.reportDiagnostic("[CLIFileNameSource] Ignoring invalid watcher path '" + relative + "':", error);
this.dependencies.onInvalidPath();
return;
}
// Name coverage is independent of whether synchronisation accepts this event.
this.dependencies.onObservation(type, filePath);
// Delivery may be stopped or excluded while name observation remains valid.
if (this.deliveryStopped || !this.isEligibleForDelivery(filePath)) {
return;
}
const event: QueuedWatchEvent = { type, path: filePath, stats };
if (!this.deliveryActive) {
this.bufferedEvents.push(event);
return;
}
this.deliver(event);
}
/** These exclusions apply only after inventory invalidation, including operation-time suppression. */
private isEligibleForDelivery(filePath: FilePath): boolean {
const relative = String(filePath);
if (isHiddenPath(relative) || ignoredSyncSuffix.test(relative)) {
return false;
}
if (this.options.ignoreRules?.shouldIgnore(relative)) {
return false;
}
try {
return !(this.options.suppressEvent?.(relative) ?? false);
} catch (error) {
this.reportDiagnostic(
"[CLIFileNameSource] Could not evaluate event suppression for '" + relative + "':",
error
);
return false;
}
}
private deliver(event: QueuedWatchEvent): void {
const handlers = this.handlers;
if (!handlers || this.deliveryStopped) {
return;
}
if (event.type === "add" || event.type === "change" || event.type === "unlink") {
const file = this.toNodeFile(event.path, event.stats);
if (event.type === "add") {
handlers.onCreate(file);
} else if (event.type === "change") {
handlers.onChange(file);
} else {
handlers.onDelete(file);
}
return;
}
const folder: NodeFolder = { path: event.path, isFolder: true };
if (event.type === "addDir") {
handlers.onCreate(folder);
} else {
handlers.onDelete(folder);
}
}
private toNodeFile(filePath: FilePath, stats?: Stats): NodeFile {
const now = Date.now();
return {
path: filePath,
stat: {
ctime: stats ? getNodeFileCreationTime(stats) : now,
mtime: Math.floor(stats?.mtimeMs ?? now),
size: stats?.size ?? 0,
type: "file",
},
};
}
private relativePath(candidatePath: string): string | null {
const absolute = path.isAbsolute(candidatePath)
? path.resolve(candidatePath)
: path.resolve(this.basePath, candidatePath);
const relative = path.relative(this.basePath, absolute).replace(/\\/g, "/");
if (relative === ".." || relative.startsWith("../") || path.isAbsolute(relative)) {
return null;
}
return relative;
}
}
@@ -0,0 +1,313 @@
import { EventEmitter } from "node:events";
import { os, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
import type { ChokidarOptions, FSWatcher } from "chokidar";
import type { IStorageEventWatchHandlers } from "@vrtmrz/livesync-commonlib/compat/managers/adapters";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { NodeFile, NodeFolder } from "@/apps/cli/adapters/NodeTypes";
import { CLIFileNameSourceWatch } from "./CLIFileNameSource.watch";
type WatchDependencies = ConstructorParameters<typeof CLIFileNameSourceWatch>[0];
type WatchOverrides = Partial<Omit<WatchDependencies, "basePath" | "options">> & {
options?: Partial<WatchDependencies["options"]>;
};
type WatchItem = NodeFile | NodeFolder;
type TestWatcher = {
readonly eventEmitter: EventEmitter;
readonly watcher: FSWatcher;
readonly close: ReturnType<typeof vi.fn>;
};
type TestWatch = {
readonly source: CLIFileNameSourceWatch;
readonly watcher: TestWatcher;
readonly dependencies: WatchDependencies;
readonly watcherFactory: ReturnType<typeof vi.fn>;
};
function makeWatcher(): TestWatcher {
const eventEmitter = new EventEmitter();
const close = vi.fn(async () => undefined);
const watcher = Object.assign(eventEmitter, { close }) as unknown as FSWatcher;
return { eventEmitter, watcher, close };
}
function makeWatch(basePath: string, overrides: WatchOverrides = {}): TestWatch {
const watcher = makeWatcher();
const watcherFactory = vi.fn((_basePath: string, _options: ChokidarOptions) => watcher.watcher);
const { options: optionOverrides, ...dependencyOverrides } = overrides;
const dependencies: WatchDependencies = {
basePath,
options: { basePath, watcherFactory, ...optionOverrides },
reportDiagnostic: vi.fn(),
canObserve: vi.fn(() => true),
canDeliver: vi.fn(() => true),
onObservation: vi.fn(),
onInvalidPath: vi.fn(),
onFatalError: vi.fn(),
...dependencyOverrides,
};
return { source: new CLIFileNameSourceWatch(dependencies), watcher, dependencies, watcherFactory };
}
async function startObservation(testWatch: TestWatch): Promise<void> {
const started = testWatch.source.startObservation();
testWatch.watcher.eventEmitter.emit("ready");
await started;
}
function emit(testWatch: TestWatch, type: string, candidatePath: string, stats?: Stats): void {
testWatch.watcher.eventEmitter.emit(type, candidatePath, stats);
}
function makeHandlers(events: string[] = []) {
const recordDelivery = (action: string, item: WatchItem): void => {
const kind = "isFolder" in item ? "folder" : "file";
events.push(`${action}:${item.path}:${kind}`);
};
const onCreate = vi.fn((item: WatchItem) => recordDelivery("create", item));
const onChange = vi.fn((item: WatchItem) => recordDelivery("change", item));
const onDelete = vi.fn((item: WatchItem) => recordDelivery("delete", item));
const handlers = {
onCreate,
onChange,
onDelete,
onRename: vi.fn(),
onRaw: vi.fn(),
} as unknown as IStorageEventWatchHandlers;
return { handlers, onCreate, onChange, onDelete };
}
const basePath = path.join(os.tmpdir(), "livesync-cli-file-watch-unit-root");
afterEach(() => {
vi.restoreAllMocks();
});
describe("CLIFileNameSourceWatch", () => {
it("rejects observation started after close without acquiring a watcher", async () => {
const testWatch = makeWatch(basePath);
await testWatch.source.close();
await expect(testWatch.source.startObservation()).rejects.toThrow("source is closed");
expect(testWatch.watcherFactory).not.toHaveBeenCalled();
});
it("cancels a delayed ready wait and ignores readiness delivered afterwards", async () => {
const testWatch = makeWatch(basePath);
const started = testWatch.source.startObservation();
const rejected = expect(started).rejects.toThrow("closed before watcher readiness");
await testWatch.source.close();
testWatch.watcher.eventEmitter.emit("ready");
await rejected;
await testWatch.source.close();
expect(testWatch.watcher.close).toHaveBeenCalledOnce();
});
it("rejects repeated delivery activation while preserving the first handlers", async () => {
const testWatch = makeWatch(basePath);
await startObservation(testWatch);
const first = makeHandlers();
const second = makeHandlers();
testWatch.source.beginDelivery(first.handlers);
expect(() => testWatch.source.beginDelivery(second.handlers)).toThrow("already started");
emit(testWatch, "add", path.join(basePath, "note.md"));
expect(first.onCreate).toHaveBeenCalledOnce();
expect(second.onCreate).not.toHaveBeenCalled();
await testWatch.source.close();
});
it("keeps observing after delivery stops and rejects later activation", async () => {
const testWatch = makeWatch(basePath);
await startObservation(testWatch);
const handlers = makeHandlers();
testWatch.source.beginDelivery(handlers.handlers);
testWatch.source.stopDelivery();
emit(testWatch, "add", path.join(basePath, "after-stop.md"));
expect(testWatch.dependencies.onObservation).toHaveBeenCalledOnce();
expect(handlers.onCreate).not.toHaveBeenCalled();
expect(() => testWatch.source.beginDelivery(makeHandlers().handlers)).toThrow("delivery has been stopped");
await testWatch.source.close();
});
it("invalidates before sync-only exclusions and delivers accepted events in arrival order", async () => {
const testWatch = makeWatch(basePath);
await startObservation(testWatch);
const events: string[] = [];
const handlers = makeHandlers(events);
const addPath = path.join(basePath, "note.md");
const folderPath = path.join(basePath, "folder");
emit(testWatch, "add", addPath);
emit(testWatch, "change", addPath);
emit(testWatch, "unlink", addPath);
emit(testWatch, "addDir", folderPath);
emit(testWatch, "unlinkDir", folderPath);
emit(testWatch, "add", path.join(basePath, ".hidden.md"));
emit(testWatch, "add", path.join(basePath, "internal-livesync-v2", "record.md"));
expect(
vi.mocked(testWatch.dependencies.onObservation).mock.calls.map(([type, filePath]) => `${type}:${filePath}`)
).toEqual([
"add:note.md",
"change:note.md",
"unlink:note.md",
"addDir:folder",
"unlinkDir:folder",
"add:.hidden.md",
"add:internal-livesync-v2/record.md",
]);
const beforeDelivery = Date.now();
testWatch.source.beginDelivery(handlers.handlers);
const afterDelivery = Date.now();
expect(events).toEqual([
"create:note.md:file",
"change:note.md:file",
"delete:note.md:file",
"create:folder:folder",
"delete:folder:folder",
]);
const created = handlers.onCreate.mock.calls[0]?.[0];
expect(created).toBeDefined();
if (!created || !("stat" in created)) {
throw new Error("Expected a file notification with fallback metadata");
}
expect(created.stat.size).toBe(0);
expect(created.stat.type).toBe("file");
expect(created.stat.mtime).toBeGreaterThanOrEqual(beforeDelivery);
expect(created.stat.mtime).toBeLessThanOrEqual(afterDelivery);
expect(created.stat.ctime).toBe(created.stat.mtime);
const deletedFolder = handlers.onDelete.mock.calls[1]?.[0];
expect(deletedFolder).toEqual({ path: "folder", isFolder: true });
await testWatch.source.close();
});
it("drops buffered delivery while the caller disallows delivery", async () => {
const testWatch = makeWatch(basePath, { canDeliver: () => false });
await startObservation(testWatch);
emit(testWatch, "add", path.join(basePath, "buffered.md"));
const handlers = makeHandlers();
testWatch.source.beginDelivery(handlers.handlers);
expect(testWatch.dependencies.onObservation).toHaveBeenCalledOnce();
expect(handlers.onCreate).not.toHaveBeenCalled();
await testWatch.source.close();
});
it("ignores root and outside paths, observes hidden and sync-only paths, and reports invalid names", async () => {
const testWatch = makeWatch(basePath);
await startObservation(testWatch);
const handlers = makeHandlers();
testWatch.source.beginDelivery(handlers.handlers);
emit(testWatch, "add", basePath);
emit(testWatch, "add", path.resolve(basePath, "..", "outside.md"));
emit(testWatch, "add", path.join(basePath, ".hidden.md"));
emit(testWatch, "add", path.join(basePath, "nested-livesync-v2", "entry.md"));
emit(testWatch, "add", path.join(basePath, "C:\\invalid.md"));
expect(
vi.mocked(testWatch.dependencies.onObservation).mock.calls.map(([type, filePath]) => `${type}:${filePath}`)
).toEqual(["add:.hidden.md", "add:nested-livesync-v2/entry.md"]);
expect(testWatch.dependencies.onInvalidPath).toHaveBeenCalledOnce();
expect(testWatch.dependencies.reportDiagnostic).toHaveBeenCalledOnce();
expect(handlers.onCreate).not.toHaveBeenCalled();
await testWatch.source.close();
});
it("skips path validation and invalidation while observation is disabled", async () => {
const testWatch = makeWatch(basePath, { canObserve: () => false });
await startObservation(testWatch);
emit(testWatch, "add", path.join(basePath, "C:\\invalid.md"));
expect(testWatch.dependencies.onObservation).not.toHaveBeenCalled();
expect(testWatch.dependencies.onInvalidPath).not.toHaveBeenCalled();
expect(testWatch.dependencies.reportDiagnostic).not.toHaveBeenCalled();
await testWatch.source.close();
});
it("filters hidden directories while retaining hidden leaf files in the watcher scope", async () => {
const testWatch = makeWatch(basePath);
await startObservation(testWatch);
const options = testWatch.watcherFactory.mock.calls[0]?.[1];
const ignored = options?.ignored;
if (typeof ignored !== "function") {
throw new Error("Expected the watcher factory to receive a directory filter");
}
const directoryStats = { isDirectory: () => true } as Stats;
const fileStats = { isDirectory: () => false } as Stats;
expect(ignored(basePath)).toBe(false);
expect(ignored(path.join(basePath, ".private"), directoryStats)).toBe(true);
expect(ignored(path.join(basePath, ".private", "visible.md"), fileStats)).toBe(true);
expect(ignored(path.join(basePath, ".hidden.md"), fileStats)).toBe(false);
await testWatch.source.close();
});
it("reports suppression-hook failures while retaining name observation", async () => {
const failure = new Error("suppression failed");
const testWatch = makeWatch(basePath, {
options: {
suppressEvent: () => {
throw failure;
},
},
});
await startObservation(testWatch);
const handlers = makeHandlers();
testWatch.source.beginDelivery(handlers.handlers);
emit(testWatch, "add", path.join(basePath, "note.md"));
expect(testWatch.dependencies.onObservation).toHaveBeenCalledOnce();
expect(testWatch.dependencies.reportDiagnostic).toHaveBeenCalledWith(
expect.stringContaining("Could not evaluate event suppression"),
failure
);
expect(handlers.onCreate).not.toHaveBeenCalled();
await testWatch.source.close();
});
it("marks names unavailable, closes, and requests restart on a fatal watcher error", async () => {
const failure = new Error("watcher failed");
const exitSignal = new Error("intercepted process exit");
const testWatch = makeWatch(basePath);
const started = testWatch.source.startObservation();
const rejected = expect(started).rejects.toBe(failure);
const exit = vi.spyOn(process, "exit").mockImplementation(() => {
throw exitSignal;
});
try {
expect(() => testWatch.watcher.eventEmitter.emit("error", failure)).toThrow(exitSignal);
await rejected;
expect(testWatch.dependencies.onFatalError).toHaveBeenCalledWith(failure);
expect(testWatch.dependencies.reportDiagnostic).toHaveBeenNthCalledWith(
1,
expect.stringContaining("Fatal watcher error"),
failure
);
expect(testWatch.dependencies.reportDiagnostic).toHaveBeenNthCalledWith(
2,
expect.stringContaining("Exiting for systemd restart")
);
expect(testWatch.watcher.close).toHaveBeenCalledOnce();
expect(exit).toHaveBeenCalledWith(1);
} finally {
exit.mockRestore();
await testWatch.source.close();
}
});
});
@@ -0,0 +1,87 @@
import { UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
/** Ends the invalidation fence even if the filesystem operation fails partially. */
export type StorageMutationCompletion = () => void;
/** Begin observation before I/O and optionally return its matching completion callback. */
export type StorageMutationInvalidator = (paths: readonly string[]) => StorageMutationCompletion | void;
/** Adds inventory invalidation around the shared CLI storage mutation boundary. */
export class ObservedNodeStorageAdapter extends UnicodeResolvingNodeStorageAdapter {
constructor(
basePath: string,
getPathNormalizationVersion: () => number = () => 0,
isCaseInsensitive: () => boolean = () => false,
private readonly onMutation?: StorageMutationInvalidator
) {
super(basePath, getPathNormalizationVersion, isCaseInsensitive);
}
private async runMutation<T>(paths: readonly string[], operation: () => Promise<T>): Promise<T> {
// Capture one completion for this operation. Nested storage methods can
// create their own tokens; the source waits for all tokens in its boundary.
let complete: StorageMutationCompletion | void;
try {
complete = this.onMutation?.(paths);
} catch {
// A failed observation hook must not suppress the requested filesystem I/O.
complete = undefined;
}
try {
return await operation();
} finally {
// Failed I/O may still have changed names, so completion always runs.
try {
complete?.();
} catch {
// Inventory bookkeeping must not change the storage operation's result.
}
}
}
override async write(
path: string,
data: string,
options?: UXDataWriteOptions,
caseInsensitive?: boolean
): Promise<void> {
return await this.runMutation([path], async () => await super.write(path, data, options, caseInsensitive));
}
override async writeBinary(
path: string,
data: ArrayBuffer,
options?: UXDataWriteOptions,
caseInsensitive?: boolean
): Promise<void> {
return await this.runMutation(
[path],
async () => await super.writeBinary(path, data, options, caseInsensitive)
);
}
override async append(
path: string,
data: string,
options?: UXDataWriteOptions,
caseInsensitive?: boolean
): Promise<void> {
return await this.runMutation([path], async () => await super.append(path, data, options, caseInsensitive));
}
override async mkdir(path: string, caseInsensitive?: boolean): Promise<void> {
return await this.runMutation([path], async () => await super.mkdir(path, caseInsensitive));
}
override async remove(path: string, caseInsensitive?: boolean): Promise<void> {
return await this.runMutation([path], async () => await super.remove(path, caseInsensitive));
}
override async rename(sourcePath: string, targetPath: string, caseInsensitive?: boolean): Promise<void> {
return await this.runMutation(
[sourcePath, targetPath],
async () => await super.rename(sourcePath, targetPath, caseInsensitive)
);
}
}
@@ -0,0 +1,121 @@
import { fs, os, path, UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import { describe, expect, it, vi } from "vitest";
import { ObservedNodeStorageAdapter } from "./ObservedNodeStorageAdapter";
function makeRoot(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "livesync-observed-storage-"));
}
function removeRoot(root: string): void {
fs.rmSync(root, { recursive: true, force: true });
}
describe("ObservedNodeStorageAdapter", () => {
it("writes using the default Legacy comparison callbacks", async () => {
const root = makeRoot();
const events: string[] = [];
const storage = new ObservedNodeStorageAdapter(root, undefined, undefined, (paths) => {
events.push(`start:${paths.join(",")}`);
return () => events.push("complete");
});
try {
await storage.write("Legacy.md", "legacy contents");
expect(fs.readFileSync(path.join(root, "Legacy.md"), "utf8")).toBe("legacy contents");
expect(events).toEqual(["start:Legacy.md", "complete"]);
} finally {
removeRoot(root);
}
});
it("writes an NFC name with version 1 and the default case comparison callback", async () => {
const root = makeRoot();
const nfcPath = "caf\u00e9.md";
const events: string[] = [];
const storage = new ObservedNodeStorageAdapter(
root,
() => 1,
undefined,
(paths) => {
events.push(`start:${paths.join(",")}`);
return () => events.push("complete");
}
);
try {
await storage.write(nfcPath, "nfc contents");
expect(fs.readFileSync(path.join(root, nfcPath), "utf8")).toBe("nfc contents");
expect(events).toEqual([`start:${nfcPath}`, "complete"]);
} finally {
removeRoot(root);
}
});
it("preserves successful filesystem writes when observation setup throws", async () => {
const root = makeRoot();
const failure = new Error("observation setup failed");
const onMutation = vi.fn(() => {
throw failure;
});
const storage = new ObservedNodeStorageAdapter(root, undefined, undefined, onMutation);
try {
await expect(
storage.write("observer-start.md", "written despite observer failure")
).resolves.toBeUndefined();
expect(onMutation).toHaveBeenCalledWith(["observer-start.md"]);
expect(fs.readFileSync(path.join(root, "observer-start.md"), "utf8")).toBe(
"written despite observer failure"
);
} finally {
removeRoot(root);
}
});
it("preserves successful filesystem writes when invalidation completion throws", async () => {
const root = makeRoot();
const failure = new Error("invalidation completion failed");
const complete = vi.fn(() => {
throw failure;
});
const onMutation = vi.fn(() => complete);
const storage = new ObservedNodeStorageAdapter(root, undefined, undefined, onMutation);
try {
await expect(
storage.write("observer-complete.md", "written despite completion failure")
).resolves.toBeUndefined();
expect(onMutation).toHaveBeenCalledWith(["observer-complete.md"]);
expect(complete).toHaveBeenCalledOnce();
expect(fs.readFileSync(path.join(root, "observer-complete.md"), "utf8")).toBe(
"written despite completion failure"
);
} finally {
removeRoot(root);
}
});
it("runs invalidation completion and preserves the underlying I/O error", async () => {
const root = makeRoot();
const failure = new Error("underlying write failed");
const complete = vi.fn();
const onMutation = vi.fn(() => complete);
const storage = new ObservedNodeStorageAdapter(root, undefined, undefined, onMutation);
const write = vi.spyOn(UnicodeResolvingNodeStorageAdapter.prototype, "write").mockRejectedValue(failure);
try {
await expect(storage.write("failed-write.md", "contents")).rejects.toBe(failure);
expect(onMutation).toHaveBeenCalledWith(["failed-write.md"]);
expect(complete).toHaveBeenCalledOnce();
expect(fs.existsSync(path.join(root, "failed-write.md"))).toBe(false);
} finally {
write.mockRestore();
removeRoot(root);
}
});
});
+4
View File
@@ -77,6 +77,10 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
APIService: services.API,
pathService: services.path,
});
services.appLifecycle.onUnload.addHandler(() => {
vaultAccess.dispose();
return Promise.resolve(true);
});
const storageEventManager = new StorageEventManagerObsidian(this, core, {
fileProcessing: services.fileProcessing,
setting: services.setting,
@@ -16,6 +16,7 @@ import {
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { EVENT_CONFLICT_CANCELLED, EVENT_PLUGIN_UNLOADED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents";
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
import type { FileNameReadOptions } from "@vrtmrz/livesync-commonlib/storage";
import type { IAPIService } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
import {
createInstanceLogFunction,
@@ -58,7 +59,9 @@ function createHarness(options: HarnessOptions = {}) {
};
const addLog = vi.fn();
const storageAccess = {
getFileNames: vi.fn(async () => options.files ?? []),
getFileNames: vi.fn<(readOptions?: FileNameReadOptions) => Promise<FilePathWithPrefix[]>>(
async () => options.files ?? []
),
};
const activeFile = vi.fn(() => options.activeFile);
const services = {
@@ -262,6 +265,41 @@ describe("conflict resolution serviceFeature", () => {
expect(API.addLog).toHaveBeenLastCalledWith("Done!", LOG_LEVEL_NOTICE, "resolveAllConflictedFilesByNewerOnes");
});
it("uses a fresh supported workset before bulk conflict revision deletion", async () => {
const harness = createHarness();
const currentPath = "current.md" as FilePathWithPrefix;
const removedPath = "removed-before-watch-delivery.md" as FilePathWithPrefix;
harness.storageAccess.getFileNames.mockImplementation((options) =>
Promise.resolve(options?.fresh ? [currentPath] : [removedPath])
);
harness.databaseFileAccess.fetchEntryMeta.mockImplementation((path: FilePathWithPrefix, rev?: string) =>
Promise.resolve(metadata(path, rev ?? "2-current", rev ? 1000 : 2000))
);
harness.databaseFileAccess.getConflictedRevs.mockResolvedValue(["1-older"]);
await harness.conflict.resolveAllConflictedFilesByNewerOnes();
expect(harness.fileHandler.deleteRevisionFromDB).toHaveBeenCalledExactlyOnceWith(currentPath, "1-older");
expect(harness.databaseFileAccess.fetchEntryMeta.mock.calls.every(([path]) => path === currentPath)).toBe(true);
expect(harness.storageAccess.getFileNames).toHaveBeenCalledExactlyOnceWith({ fresh: true });
});
it("does not delete conflict revisions when fresh bulk enumeration fails", async () => {
const harness = createHarness();
const failure = new Error("fresh listing could not validate its scope");
harness.storageAccess.getFileNames.mockImplementation((options) => {
if (options?.fresh) {
return Promise.reject(failure);
}
return Promise.resolve(["cached.md" as FilePathWithPrefix]);
});
await expect(harness.conflict.resolveAllConflictedFilesByNewerOnes()).rejects.toBe(failure);
expect(harness.databaseFileAccess.fetchEntryMeta).not.toHaveBeenCalled();
expect(harness.fileHandler.deleteRevisionFromDB).not.toHaveBeenCalled();
});
it("keeps resolver operations on narrow collaborators and extension seams", async () => {
const harness = createOperationsHarness();
const path = "same.md" as FilePathWithPrefix;
@@ -294,7 +294,10 @@ export function createConflictResolutionOperations(
const resolveAllConflictedFilesByNewerOnes = async (): Promise<void> => {
dependencies.log(`Resolving conflicts by newer ones`, LOG_LEVEL_NOTICE);
const files = await dependencies.storageAccess.getFileNames();
// This explicit bulk action deletes older revisions. Read the host's
// supported listing anew before choosing its workset; a listing failure
// must abort before any revision is removed.
const files = await dependencies.storageAccess.getFileNames({ fresh: true });
let i = 0;
for (const file of files) {
@@ -81,6 +81,8 @@ export async function findDiagnosticFiles(
reportProgress();
return cancelledResult(processed, candidates);
}
// The watched listing supplies a diagnostic existence hint. Membership here
// authorises no file or revision mutation, so a fresh full scan is unnecessary.
const vaultPaths = new Set(await core.storageAccess.getFileNames());
const matchingMetadataPaths = new Set<string>();
if (options.signal?.aborted) {
+2
View File
@@ -463,6 +463,8 @@ export async function copyFileDatabaseInfo(core: FileDatabaseInfoCore, path: str
export async function collectFileDatabaseInfoPaths(core: FileDatabaseInfoCore): Promise<string[]> {
const ignorePatterns = getFileRegExp(core.settings, "syncInternalFilesIgnorePatterns");
const targetPatterns = getFileRegExp(core.settings, "syncInternalFilesTargetPatterns");
// These names seed a chooser or inspection; per-entry reads establish current
// state before offering actions. Hidden-file mode keeps its separate listing scope.
const storagePaths = core.settings.syncInternalFiles
? await core.storageAccess.getFilesIncludeHidden("/", targetPatterns, ignorePatterns)
: await core.storageAccess.getFileNames();
+4
View File
@@ -11,4 +11,8 @@ export class FileAccessObsidian extends FileAccessBase<ObsidianFileSystemAdapter
const adapter = new ObsidianFileSystemAdapter(app);
super(adapter, dependencies);
}
dispose(): void {
this.adapter.dispose();
}
}
@@ -0,0 +1,113 @@
import {
FilePathIndex,
type FileNameLookup,
type FileNameReadOptions,
type FilePath,
type PathComparisonSettings,
} from "@vrtmrz/livesync-commonlib/storage";
import type { EventRef, TAbstractFile, TFolder, Vault } from "obsidian";
function isFolder(file: TAbstractFile): file is TFolder {
return "children" in file;
}
function isInsideFolder(path: FilePath, folderPath: string): boolean {
return path.startsWith(`${folderPath}/`);
}
/** Maintains file-name membership from Obsidian's in-memory Vault listing and events. */
export class ObsidianFileNameSource implements FileNameLookup {
private readonly index = new FilePathIndex();
private readonly eventRefs: EventRef[] = [];
private disposed = false;
constructor(private readonly vault: Vault) {
try {
this.eventRefs.push(this.vault.on("create", this.onCreate));
this.eventRefs.push(this.vault.on("delete", this.onDelete));
this.eventRefs.push(this.vault.on("rename", this.onRename));
this.index.replaceAll(this.getVaultFileNames());
} catch (error) {
this.dispose();
throw error;
}
}
async getFileNames(options?: FileNameReadOptions): Promise<FilePath[]> {
this.assertActive();
if (options?.fresh) {
const fileNames = this.getVaultFileNames();
this.index.replaceAll(fileNames);
return await Promise.resolve(fileNames);
}
return await Promise.resolve(this.index.getFileNames());
}
async findPathIdentityCandidates(
path: FilePath,
policy: PathComparisonSettings
): Promise<readonly FilePath[]> {
this.assertActive();
return await Promise.resolve(this.index.findPathIdentityCandidates(path, policy));
}
dispose(): void {
if (this.disposed) {
return;
}
this.disposed = true;
for (const eventRef of this.eventRefs.splice(0)) {
this.vault.offref(eventRef);
}
}
private readonly onCreate = (file: TAbstractFile): void => {
if (this.disposed) {
return;
}
if (isFolder(file)) {
const prefix = `${file.path}/`;
const added = this.getVaultFileNames().filter((path) => path.startsWith(prefix));
this.index.apply({ removed: [], added });
return;
}
this.index.apply({ removed: [], added: [file.path as FilePath] });
};
private readonly onDelete = (file: TAbstractFile): void => {
if (this.disposed) {
return;
}
if (isFolder(file)) {
const removed = this.index.getFileNames().filter((path) => isInsideFolder(path, file.path));
this.index.apply({ removed, added: [] });
return;
}
this.index.apply({ removed: [file.path as FilePath], added: [] });
};
private readonly onRename = (file: TAbstractFile, oldPath: string): void => {
if (this.disposed) {
return;
}
if (isFolder(file)) {
const oldPrefix = `${oldPath}/`;
const newPrefix = `${file.path}/`;
const removed = this.index.getFileNames().filter((path) => path.startsWith(oldPrefix));
const added = removed.map((path) => `${newPrefix}${path.slice(oldPrefix.length)}` as FilePath);
this.index.apply({ removed, added });
return;
}
this.index.apply({ removed: [oldPath as FilePath], added: [file.path as FilePath] });
};
private getVaultFileNames(): FilePath[] {
return this.vault.getFiles().map((file) => file.path as FilePath);
}
private assertActive(): void {
if (this.disposed) {
throw new Error("Obsidian file-name source has been disposed");
}
}
}
@@ -7,12 +7,14 @@ import type {
IStorageAdapter,
IVaultAdapter,
} from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
import type { FileNameLookup } from "@vrtmrz/livesync-commonlib/storage";
import type { TAbstractFile, TFile, TFolder, Stat, App } from "obsidian";
import { ObsidianConversionAdapter } from "./ObsidianConversionAdapter";
import { ObsidianPathAdapter } from "./ObsidianPathAdapter";
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
import { ObsidianTypeGuardAdapter } from "./ObsidianTypeGuardAdapter";
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
import { ObsidianFileNameSource } from "./ObsidianFileNameSource";
declare module "obsidian" {
interface Vault {
@@ -33,6 +35,8 @@ export class ObsidianFileSystemAdapter implements IFileSystemAdapter<TAbstractFi
readonly conversion: IConversionAdapter<TFile, TFolder>;
readonly storage: IStorageAdapter<Stat>;
readonly vault: IVaultAdapter<TFile>;
readonly fileNameLookup: FileNameLookup;
private readonly fileNameSource: ObsidianFileNameSource;
constructor(private app: App) {
this.path = new ObsidianPathAdapter();
@@ -40,6 +44,8 @@ export class ObsidianFileSystemAdapter implements IFileSystemAdapter<TAbstractFi
this.conversion = new ObsidianConversionAdapter();
this.storage = new ObsidianStorageAdapter(app);
this.vault = new ObsidianVaultAdapter(app);
this.fileNameSource = new ObsidianFileNameSource(app.vault);
this.fileNameLookup = this.fileNameSource;
}
getAbstractFileByPath(path: FilePath | string): Promise<TAbstractFile | null> {
@@ -66,4 +72,8 @@ export class ObsidianFileSystemAdapter implements IFileSystemAdapter<TAbstractFi
async reconcileInternalFile(path: string): Promise<void> {
return await Promise.resolve(this.app.vault.adapter.reconcileInternalFile?.(path));
}
dispose(): void {
this.fileNameSource.dispose();
}
}
@@ -0,0 +1,227 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { App, TAbstractFile, TFile, TFolder } from "obsidian";
import { FilePathIndex, type FilePath, type PathComparisonSettings } from "@vrtmrz/livesync-commonlib/storage";
import { ObsidianFileSystemAdapter } from "./ObsidianFileSystemAdapter";
vi.mock("./ObsidianConversionAdapter", () => ({ ObsidianConversionAdapter: class {} }));
vi.mock("./ObsidianPathAdapter", () => ({ ObsidianPathAdapter: class {} }));
vi.mock("./ObsidianStorageAdapter", () => ({ ObsidianStorageAdapter: class {} }));
vi.mock("./ObsidianTypeGuardAdapter", () => ({ ObsidianTypeGuardAdapter: class {} }));
vi.mock("./ObsidianVaultAdapter", () => ({ ObsidianVaultAdapter: class {} }));
afterEach(() => vi.restoreAllMocks());
type VaultEvent = "create" | "delete" | "rename" | "modify";
type VaultEventHandler = (file: TAbstractFile, oldPath?: string) => void;
type TestFileNameLookup = {
getFileNames(options?: { fresh?: boolean }): Promise<FilePath[]>;
findPathIdentityCandidates(path: FilePath, policy: PathComparisonSettings): Promise<readonly FilePath[]>;
};
function makeFile(path: string): TFile {
return { path } as TFile;
}
function makeFolder(path: string, children: TAbstractFile[] = []): TFolder {
return { path, children } as TFolder;
}
function getLookup(adapter: ObsidianFileSystemAdapter): TestFileNameLookup | undefined {
return (adapter as unknown as { fileNameLookup?: TestFileNameLookup }).fileNameLookup;
}
async function sortedFileNames(lookup: TestFileNameLookup): Promise<FilePath[]> {
return (await lookup.getFileNames()).sort();
}
function makeApp(initialFiles: TFile[]) {
const handlers = new Map<VaultEvent, VaultEventHandler[]>();
const refs: object[] = [];
const files = [...initialFiles];
const on = vi.fn((event: VaultEvent, handler: VaultEventHandler) => {
const eventHandlers = handlers.get(event) ?? [];
eventHandlers.push(handler);
handlers.set(event, eventHandlers);
const ref = {};
refs.push(ref);
return ref;
});
const getFiles = vi.fn(() => [...files]);
const offref = vi.fn();
const app = {
vault: {
getFiles,
on,
offref,
},
} as unknown as App;
return {
app,
files,
handlers,
on,
offref,
refs,
getFiles,
emit(event: VaultEvent, file: TAbstractFile, oldPath?: string) {
for (const handler of handlers.get(event) ?? []) {
handler(file, oldPath);
}
},
};
}
describe("ObsidianFileSystemAdapter file-name lookup", () => {
it("seeds the lookup from the Vault after registering direct membership listeners", async () => {
const note = makeFile("notes/first.md");
const mock = makeApp([note]);
const adapter = new ObsidianFileSystemAdapter(mock.app);
const lookup = getLookup(adapter);
expect(lookup).toBeDefined();
expect(mock.on.mock.calls.map(([event]) => event)).toEqual(["create", "delete", "rename"]);
expect(mock.getFiles).toHaveBeenCalledTimes(1);
expect(Math.max(...mock.on.mock.invocationCallOrder)).toBeLessThan(mock.getFiles.mock.invocationCallOrder[0]);
expect(await sortedFileNames(lookup!)).toEqual(["notes/first.md"]);
});
it("updates file and folder membership before any synchronisation filtering, without rebuilding on content edits", async () => {
const apply = vi.spyOn(FilePathIndex.prototype, "apply");
const replaceAll = vi.spyOn(FilePathIndex.prototype, "replaceAll");
const mock = makeApp([makeFile("notes/first.md"), makeFile("notes-old/keep.md")]);
const adapter = new ObsidianFileSystemAdapter(mock.app);
const lookup = getLookup(adapter);
expect(lookup).toBeDefined();
const initialEnumerationCount = mock.getFiles.mock.calls.length;
expect(replaceAll).toHaveBeenCalledTimes(1);
const added = makeFile("notes/second.md");
mock.files.push(added);
mock.emit("create", added);
mock.emit("create", added);
expect(await sortedFileNames(lookup!)).toEqual(["notes-old/keep.md", "notes/first.md", "notes/second.md"]);
mock.emit("delete", added);
expect(await sortedFileNames(lookup!)).toEqual(["notes-old/keep.md", "notes/first.md"]);
mock.emit("create", added);
mock.emit("create", added);
expect(await sortedFileNames(lookup!)).toEqual(["notes-old/keep.md", "notes/first.md", "notes/second.md"]);
added.path = "notes/renamed.md";
mock.emit("rename", added, "notes/second.md");
expect(await sortedFileNames(lookup!)).toEqual(["notes-old/keep.md", "notes/first.md", "notes/renamed.md"]);
const applyCount = apply.mock.calls.length;
const replaceAllCount = replaceAll.mock.calls.length;
mock.emit("modify", added);
expect(mock.getFiles).toHaveBeenCalledTimes(initialEnumerationCount);
expect(apply).toHaveBeenCalledTimes(applyCount);
expect(replaceAll).toHaveBeenCalledTimes(replaceAllCount);
mock.files.push(makeFile("new-folder/nested.md"));
mock.emit("create", makeFolder("new-folder"));
expect(await sortedFileNames(lookup!)).toEqual([
"new-folder/nested.md",
"notes-old/keep.md",
"notes/first.md",
"notes/renamed.md",
]);
const movedFolder = makeFolder("archive");
mock.emit("rename", movedFolder, "notes");
expect(await sortedFileNames(lookup!)).toEqual([
"archive/first.md",
"archive/renamed.md",
"new-folder/nested.md",
"notes-old/keep.md",
]);
mock.emit("delete", makeFolder("archive"));
expect(await sortedFileNames(lookup!)).toEqual(["new-folder/nested.md", "notes-old/keep.md"]);
});
it("uses the latest Vault listing for a fresh read and disposes only its own listeners", async () => {
const mock = makeApp([makeFile("notes/first.md")]);
const adapter = new ObsidianFileSystemAdapter(mock.app);
const lookup = getLookup(adapter);
expect(lookup).toBeDefined();
mock.files.push(makeFile("notes/second.md"));
expect([...(await lookup!.getFileNames({ fresh: true }))].sort()).toEqual([
"notes/first.md",
"notes/second.md",
]);
expect(mock.getFiles).toHaveBeenCalledTimes(2);
expect(await sortedFileNames(lookup!)).toEqual(["notes/first.md", "notes/second.md"]);
const sourceRefs = mock.refs.slice();
const unrelatedRef = mock.app.vault.on("modify", vi.fn());
const offref = mock.offref;
(adapter as unknown as { dispose: () => void }).dispose();
const disposedRefs = offref.mock.calls.map(([ref]) => ref);
expect(disposedRefs).toHaveLength(sourceRefs.length);
expect(disposedRefs.every((ref, index) => ref === sourceRefs[index])).toBe(true);
(adapter as unknown as { dispose: () => void }).dispose();
expect(offref).toHaveBeenCalledTimes(sourceRefs.length);
expect(disposedRefs.some((ref) => ref === unrelatedRef)).toBe(false);
await expect(lookup!.getFileNames()).rejects.toThrow("disposed");
});
it("ignores late membership callbacks and rejects queries after disposal", async () => {
const mock = makeApp([makeFile("note.md")]);
const adapter = new ObsidianFileSystemAdapter(mock.app);
const lookup = getLookup(adapter)!;
const apply = vi.spyOn(FilePathIndex.prototype, "apply");
adapter.dispose();
// The host can already have captured a callback when listeners are removed.
mock.emit("create", makeFolder("later"));
mock.emit("delete", makeFile("note.md"));
mock.emit("rename", makeFile("renamed.md"), "note.md");
expect(apply).not.toHaveBeenCalled();
expect(mock.getFiles).toHaveBeenCalledTimes(1);
await expect(
lookup.findPathIdentityCandidates("note.md" as FilePath, {
pathNormalizationVersion: 0,
handleFilenameCaseSensitive: true,
})
).rejects.toThrow("disposed");
});
it("returns every equivalent original path under each supported comparison policy", async () => {
const mock = makeApp([
makeFile("notes/Café.md"),
makeFile("notes/café.md"),
makeFile("notes/Cafe\u0301.md"),
makeFile("notes/cafe\u0301.md"),
]);
const adapter = new ObsidianFileSystemAdapter(mock.app);
const lookup = getLookup(adapter)!;
const path = "notes/Café.md" as FilePath;
const policies: [PathComparisonSettings, readonly string[]][] = [
[{ pathNormalizationVersion: 0, handleFilenameCaseSensitive: true }, ["notes/Café.md"]],
[{ pathNormalizationVersion: 0, handleFilenameCaseSensitive: false }, ["notes/Café.md", "notes/café.md"]],
[
{ pathNormalizationVersion: 1, handleFilenameCaseSensitive: true },
["notes/Café.md", "notes/Cafe\u0301.md"],
],
[
{ pathNormalizationVersion: 1, handleFilenameCaseSensitive: false },
["notes/Café.md", "notes/café.md", "notes/Cafe\u0301.md", "notes/cafe\u0301.md"],
],
];
for (const [policy, expected] of policies) {
const matches = await lookup.findPathIdentityCandidates(path, policy);
expect([...matches].sort()).toEqual([...expected].sort());
}
});
it("removes its listeners if the initial Vault listing fails", () => {
const mock = makeApp([]);
mock.getFiles.mockImplementation(() => {
throw new Error("Vault listing failed");
});
expect(() => new ObsidianFileSystemAdapter(mock.app)).toThrow("Vault listing failed");
expect(mock.offref.mock.calls.map(([ref]) => ref)).toEqual(mock.refs);
});
});