Add read-only database diagnostics

This commit is contained in:
vorotamoroz committed 2026-10-19 12:49:02 +00:00
1 parent f0ea5cb3a0
commit 8aeb7c60ef
19 files changed
+1049 -236

No files matched your search

@@ -0,0 +1,9 @@
import type { DiagnosticNameMode } from "@vrtmrz/livesync-commonlib/diagnostics";
import type { DatabaseDiagnosticKind, DatabaseDiagnosticOutput } from "./databaseDiagnostics";
/** The dialogue can run or cancel an inspection without receiving the service container. */
export interface DatabaseDiagnosticDialogOptions {
kind: DatabaseDiagnosticKind;
run(nameMode: DiagnosticNameMode, onProgress: (processed: number) => void): Promise<DatabaseDiagnosticOutput>;
cancel(): void;
}
+163
View File
@@ -0,0 +1,163 @@
import {
analyseDatabaseIntegrity,
analyseDatabaseUsage,
createCouchDBDiagnosticSource,
createDiagnosticNameMapper,
createLocalDiagnosticSource,
formatDiagnosticJSON,
formatIntegrityMarkdown,
formatUsageTSV,
projectIntegrityReport,
projectUsageReport,
type DiagnosticContent,
type DiagnosticNameMode,
type DiagnosticStatus,
} from "@vrtmrz/livesync-commonlib/diagnostics";
import { REMOTE_COUCHDB, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import type { NecessaryServicesInterfaces } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import commonlibPackage from "@vrtmrz/livesync-commonlib/package.json";
export type DatabaseDiagnosticKind = "usage" | "integrity";
export type DatabaseDiagnosticHost = NecessaryServicesInterfaces<
"setting" | "database" | "path" | "API",
"storageAccess"
>;
export interface DatabaseDiagnosticArtifact {
format: "json" | "markdown" | "tsv";
filename: string;
content: string;
}
export interface DatabaseDiagnosticOutput {
status: DiagnosticStatus;
nameMode: DiagnosticNameMode;
artifacts: DatabaseDiagnosticArtifact[];
}
export async function readDiagnosticStorage(
storage: Pick<StorageAccess, "statHidden" | "readHiddenFileBinary">,
path: string
): Promise<DiagnosticContent> {
if (/^(?:[/\\]|[a-zA-Z]:)/.test(path) || path.includes("\0") || path.split(/[/\\]/).includes("..")) {
return { status: "unreadable", reason: "content-unreadable" };
}
try {
const before = await storage.statHidden(path);
if (!before) return { status: "missing" };
if (before.type !== "file") return { status: "unreadable", reason: "content-unreadable" };
const bytes = new Uint8Array(await storage.readHiddenFileBinary(path));
const after = await storage.statHidden(path);
if (
!after ||
after.type !== "file" ||
before.mtime !== after.mtime ||
before.ctime !== after.ctime ||
before.size !== after.size ||
bytes.byteLength !== after.size
) {
return { status: "unreadable", reason: "storage-changed" };
}
return { status: "available", bytes };
} catch {
return { status: "unreadable", reason: "content-unreadable" };
}
}
/** Run only after the host has collected this run's name-display choice. */
export async function runDatabaseDiagnostic(
host: DatabaseDiagnosticHost,
kind: DatabaseDiagnosticKind,
nameMode: DiagnosticNameMode,
options: { signal?: AbortSignal; onProgress?(processed: number): void } = {}
): Promise<DatabaseDiagnosticOutput> {
const configuration = JSON.stringify(host.services.setting.currentSettings());
const settings = JSON.parse(configuration) as ObsidianLiveSyncSettings;
const database = host.services.database.localDatabase.localDatabase;
const isConfigurationCurrent = () =>
JSON.stringify(host.services.setting.currentSettings()) === configuration &&
host.services.database.localDatabase.localDatabase === database;
const mapper = createDiagnosticNameMapper(nameMode);
const versions = {
commonlib: commonlibPackage.version,
plugin: host.services.API.getPluginVersion(),
application: host.services.API.getAppVersion(),
};
const local = createLocalDiagnosticSource(database);
if (kind === "usage") {
const result = await analyseDatabaseUsage({
source: local,
signal: options.signal,
onProgress: (progress) => options.onProgress?.(progress.revisionsProcessed),
});
if (!isConfigurationCurrent()) {
result.errors.push({ code: "configuration_changed" });
if (result.status === "complete") result.status = "partial";
}
const report = projectUsageReport(result, mapper, versions);
const artifacts: DatabaseDiagnosticArtifact[] = [
{
format: "json",
filename: `database-usage-${result.status}.json`,
content: formatDiagnosticJSON(report),
},
];
// A standalone legacy TSV has no coverage fields. Partial runs retain
// their rows in JSON together with the status and reasons.
if (result.status === "complete") {
artifacts.unshift({ format: "tsv", filename: "database-usage.tsv", content: formatUsageTSV(report) });
}
return { status: result.status, nameMode, artifacts };
}
const result = await analyseDatabaseIntegrity({
local,
remote:
settings.remoteType === REMOTE_COUCHDB
? () =>
Promise.resolve(
createCouchDBDiagnosticSource({
settings,
signal: options.signal,
fetch: (request, requestOptions) =>
settings.useRequestAPI
? host.services.API.nativeFetch(request, requestOptions)
: host.services.API.webCompatFetch(request, requestOptions),
})
)
: undefined,
remoteUnavailableReason: settings.remoteType === REMOTE_COUCHDB ? "source-unavailable" : "unsupported-remote",
path: {
getPath: (entry) => host.services.path.getPath(entry),
path2id: (path, prefix) => host.services.path.path2idWithSettings(path, settings, prefix),
},
settings: {
encrypt: settings.encrypt,
usePathObfuscation: settings.usePathObfuscation,
handleFilenameCaseSensitive: settings.handleFilenameCaseSensitive,
idDerivationVersion: settings.idDerivationVersion ?? 0,
pathNormalizationVersion: settings.pathNormalizationVersion ?? 0,
},
readStorage: (path) => readDiagnosticStorage(host.serviceModules.storageAccess, path),
isConfigurationCurrent,
signal: options.signal,
onProgress: (progress) => options.onProgress?.(progress.processed),
});
const report = projectIntegrityReport(result, mapper, versions);
return {
status: result.status,
nameMode,
artifacts: [
{
format: "markdown",
filename: `database-integrity-${result.status}.md`,
content: formatIntegrityMarkdown(report),
},
{
format: "json",
filename: `database-integrity-${result.status}.json`,
content: formatDiagnosticJSON(report),
},
],
};
}
@@ -0,0 +1,213 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { readDiagnosticStorage, runDatabaseDiagnostic, type DatabaseDiagnosticHost } from "./databaseDiagnostics";
function fixture() {
const path = "Private/日本語.md";
const body = "private file contents";
const bytes = new TextEncoder().encode(body);
const settings = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_P2P,
readChunksOnline: true,
passphrase: "private encryption passphrase",
couchDB_PASSWORD: "private credential",
couchDB_URI: "https://private-server.invalid",
};
const documents = {
"f:private-actual-id": {
_id: "f:private-actual-id",
_rev: "1-private-revision",
_revs_info: [{ rev: "1-private-revision", status: "available" }],
type: "plain",
path,
ctime: 1,
mtime: 1,
size: bytes.byteLength,
children: ["h:private-chunk"],
},
"h:private-chunk": {
_id: "h:private-chunk",
_rev: "1-private-chunk-revision",
_revs_info: [{ rev: "1-private-chunk-revision", status: "available" }],
type: "leaf",
data: body,
},
};
const get = vi.fn(async (id: string) => {
const doc = documents[id as keyof typeof documents];
if (!doc) throw { status: 404 };
return structuredClone(doc);
});
const info = vi.fn(async () => ({ update_seq: "1-stable" }));
const put = vi.fn(() => {
throw new Error("Diagnostic attempted a write");
});
const applyPartial = vi.fn();
const fetch = vi.fn();
const storage = {
statHidden: vi.fn(async () => ({ type: "file" as const, ctime: 1, mtime: 1, size: bytes.byteLength })),
readHiddenFileBinary: vi.fn(async () => bytes.buffer),
};
const host = {
services: {
setting: { currentSettings: () => settings, applyPartial },
database: {
localDatabase: {
localDatabase: {
info,
get,
put,
allDocs: vi.fn(async () => ({
rows: Object.keys(documents).map((id) => ({ id, value: { rev: "1-current" } })),
})),
},
},
},
path: {
getPath: (doc: { path: string }) => doc.path,
path2idWithSettings: vi.fn(async (rawPath: string) => `f:expected:${rawPath}`),
},
API: {
webCompatFetch: fetch,
nativeFetch: fetch,
getPluginVersion: () => "1.0.38",
getAppVersion: () => "1.12.7",
},
},
serviceModules: { storageAccess: storage },
} as unknown as DatabaseDiagnosticHost;
return { host, settings, path, body, bytes, get, info, put, applyPartial, fetch, storage };
}
describe("database diagnostic host composition", () => {
it("keeps on-demand fetching enabled while analysing only existing local revisions", async () => {
const data = fixture();
const before = JSON.stringify(data.settings);
const result = await runDatabaseDiagnostic(data.host, "usage", "pseudonym");
expect(result.status).toBe("complete");
expect(result.artifacts.map((artifact) => artifact.format)).toEqual(["tsv", "json"]);
expect(data.settings.readChunksOnline).toBe(true);
expect(JSON.stringify(data.settings)).toBe(before);
expect(data.applyPartial).not.toHaveBeenCalled();
expect(data.fetch).not.toHaveBeenCalled();
expect(data.put).not.toHaveBeenCalled();
expect(data.get).toHaveBeenCalledWith("f:private-actual-id", { rev: "1-private-revision" });
const report = JSON.parse(result.artifacts.find((artifact) => artifact.format === "json")!.content);
expect(report.versions).toMatchObject({ plugin: "1.0.38", application: "1.12.7" });
expect(report.versions.commonlib).toMatch(/^\d+\.\d+\.\d+/);
for (const artifact of result.artifacts) {
for (const secret of [
data.path,
data.body,
data.settings.passphrase,
"private-actual-id",
"private-revision",
"private-server.invalid",
]) {
expect(artifact.content).not.toContain(secret);
}
}
});
it("keeps local findings when the remote type is unsupported and projects both formats consistently", async () => {
const data = fixture();
const result = await runDatabaseDiagnostic(data.host, "integrity", "pseudonym");
expect(result.status).toBe("partial");
expect(data.fetch).not.toHaveBeenCalled();
expect(data.put).not.toHaveBeenCalled();
expect(data.storage.readHiddenFileBinary).toHaveBeenCalledWith(data.path);
const json = JSON.parse(result.artifacts.find((artifact) => artifact.format === "json")!.content);
expect(json.errors).toContainEqual({ source: "remote", reason: "unsupported-remote" });
expect(json.observations[0]).toMatchObject({
identity: "document-id-mismatch",
content: { status: "available" },
});
expect(json.observations[0].comparisons).toContainEqual({
against: json.observations[0].vault.reference,
relation: "equal",
});
for (const artifact of result.artifacts) {
expect(artifact.content).toContain(json.observations[0].path);
expect(artifact.content).not.toContain(data.path);
expect(artifact.content).not.toContain(data.body);
expect(artifact.content).not.toContain(data.settings.passphrase);
}
});
it("includes names only when requested and never includes credentials or file contents", async () => {
const data = fixture();
const result = await runDatabaseDiagnostic(data.host, "usage", "original");
for (const artifact of result.artifacts) {
expect(artifact.content).toContain(data.path);
expect(artifact.content).toContain("f:private-actual-id");
expect(artifact.content).not.toContain(data.body);
expect(artifact.content).not.toContain(data.settings.couchDB_PASSWORD);
expect(artifact.content).not.toContain(data.settings.couchDB_URI);
}
});
it("retains cancellation and configuration changes in JSON instead of presenting a complete TSV", async () => {
const cancelled = fixture();
const controller = new AbortController();
controller.abort();
const stopped = await runDatabaseDiagnostic(cancelled.host, "usage", "pseudonym", {
signal: controller.signal,
});
expect(stopped.status).toBe("cancelled");
expect(stopped.artifacts.map((artifact) => artifact.format)).toEqual(["json"]);
expect(cancelled.get).not.toHaveBeenCalled();
const changed = fixture();
changed.info.mockImplementationOnce(async () => ({ update_seq: "1-stable" }));
changed.info.mockImplementationOnce(async () => {
changed.settings.couchDB_URI = "https://changed.invalid";
return { update_seq: "1-stable" };
});
const output = await runDatabaseDiagnostic(changed.host, "usage", "original");
expect(output.status).toBe("partial");
expect(output.artifacts.map((artifact) => artifact.format)).toEqual(["json"]);
expect(JSON.parse(output.artifacts[0].content).errors).toContainEqual({ code: "configuration_changed" });
});
});
describe("diagnostic Vault reads", () => {
it("distinguishes an absent file, a read error, and a file changing during its read", async () => {
const data = fixture();
data.storage.statHidden.mockResolvedValueOnce(null as never);
expect(await readDiagnosticStorage(data.storage, data.path)).toEqual({ status: "missing" });
data.storage.readHiddenFileBinary.mockRejectedValueOnce(new Error("private filename and content"));
expect(await readDiagnosticStorage(data.storage, data.path)).toEqual({
status: "unreadable",
reason: "content-unreadable",
});
data.storage.statHidden.mockResolvedValueOnce({
type: "file",
ctime: 1,
mtime: 1,
size: data.bytes.byteLength,
});
data.storage.statHidden.mockResolvedValueOnce({
type: "file",
ctime: 1,
mtime: 2,
size: data.bytes.byteLength,
});
expect(await readDiagnosticStorage(data.storage, data.path)).toEqual({
status: "unreadable",
reason: "storage-changed",
});
});
it("rejects paths outside the Vault before touching storage", async () => {
const data = fixture();
for (const path of ["../private", "/private", "C:\\private", "x/../private", "x\0y"]) {
expect(await readDiagnosticStorage(data.storage, path)).toEqual({
status: "unreadable",
reason: "content-unreadable",
});
}
expect(data.storage.statHidden).not.toHaveBeenCalled();
expect(data.storage.readHiddenFileBinary).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,76 @@
import {
createServiceFeature,
type NecessaryServices,
} from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import { EVENT_ANALYSE_DB_USAGE, EVENT_INSPECT_DB_INTEGRITY } from "@/common/events";
import { $msg } from "@/common/translation";
import DatabaseDiagnosticDialog from "@/modules/services/LiveSyncUI/dialogues/DatabaseDiagnosticDialog.svelte";
import { runDatabaseDiagnostic, type DatabaseDiagnosticKind } from "./databaseDiagnostics";
import type { DatabaseDiagnosticDialogOptions } from "./databaseDiagnosticDialogTypes";
type DatabaseDiagnosticsFeatureHost = NecessaryServices<
"setting" | "database" | "path" | "API" | "UI" | "appLifecycle",
"storageAccess"
>;
export const useDatabaseDiagnosticsFeature = createServiceFeature((host: DatabaseDiagnosticsFeatureHost) => {
const { services } = host;
let active: AbortController | undefined;
const open = async (kind: DatabaseDiagnosticKind): Promise<void> => {
if (active) return;
const controller = new AbortController();
active = controller;
try {
await services.UI.dialogManager.open<"close", DatabaseDiagnosticDialogOptions>(DatabaseDiagnosticDialog, {
kind,
run: async (nameMode, onProgress) => {
if (!services.database.isDatabaseReady()) throw new Error("local-database-unavailable");
return await runDatabaseDiagnostic(host, kind, nameMode, {
signal: controller.signal,
onProgress,
});
},
cancel: () => controller.abort(),
});
} catch {
// Closing the host during unload can reject the dialogue result.
// Diagnostic errors are presented inside the dialogue without raw logs.
} finally {
controller.abort();
if (active === controller) active = undefined;
}
};
services.appLifecycle.onInitialise.addHandler(() => {
services.API.addCommand({
id: "analyse-database",
name: "Analyse Database Usage (advanced)",
icon: "database-search",
checkCallback: (checking) => {
if (!services.setting.currentSettings().useAdvancedMode || !services.database.isDatabaseReady())
return false;
if (!checking) void open("usage");
return true;
},
});
services.API.addCommand({
id: "inspect-database-integrity",
name: $msg("Inspect database integrity"),
icon: "database-search",
checkCallback: (checking) => {
if (!services.database.isDatabaseReady()) return false;
if (!checking) void open("integrity");
return true;
},
});
return Promise.resolve(true);
});
const stopUsage = services.context.events.onEvent(EVENT_ANALYSE_DB_USAGE, () => void open("usage"));
const stopIntegrity = services.context.events.onEvent(EVENT_INSPECT_DB_INTEGRITY, () => void open("integrity"));
services.appLifecycle.onUnload.addHandler(() => {
active?.abort();
stopUsage();
stopIntegrity();
return Promise.resolve(true);
});
return { open };
});
@@ -0,0 +1,122 @@
import { describe, expect, it, vi } from "vitest";
import type { DatabaseDiagnosticDialogOptions } from "./databaseDiagnosticDialogTypes";
vi.mock("@/modules/services/LiveSyncUI/dialogues/DatabaseDiagnosticDialog.svelte", () => ({ default: {} }));
vi.mock("./databaseDiagnostics", () => ({
runDatabaseDiagnostic: vi.fn(async () => ({ status: "complete", nameMode: "pseudonym", artifacts: [] })),
}));
vi.mock("@/common/translation", () => ({ $msg: (value: string) => value }));
import { runDatabaseDiagnostic } from "./databaseDiagnostics";
import { useDatabaseDiagnosticsFeature } from "./useDatabaseDiagnostics";
import { EVENT_ANALYSE_DB_USAGE, EVENT_INSPECT_DB_INTEGRITY } from "@/common/events";
function fixture() {
const initialise: Array<() => Promise<boolean>> = [];
const unload: Array<() => Promise<boolean>> = [];
const commands: Array<{ id: string; checkCallback: (checking: boolean) => boolean }> = [];
const listeners = new Map<string, () => void>();
const unsubscribe = vi.fn((event: string) => listeners.delete(event));
const settings = { useAdvancedMode: false };
let ready = true;
let close!: () => void;
const openDialog = vi.fn(
(_component: unknown, _options: DatabaseDiagnosticDialogOptions) =>
new Promise<void>((resolve) => {
close = resolve;
})
);
const host = {
services: {
setting: { currentSettings: () => settings },
database: { isDatabaseReady: () => ready },
path: {},
API: { addCommand: (command: (typeof commands)[number]) => commands.push(command) },
UI: { dialogManager: { open: openDialog } },
appLifecycle: {
onInitialise: { addHandler: (handler: (typeof initialise)[number]) => initialise.push(handler) },
onUnload: { addHandler: (handler: (typeof unload)[number]) => unload.push(handler) },
},
context: {
events: {
onEvent: (event: string, handler: () => void) => {
listeners.set(event, handler);
return () => unsubscribe(event);
},
},
},
},
serviceModules: { storageAccess: {} },
} as unknown as Parameters<typeof useDatabaseDiagnosticsFeature>[0];
const view = useDatabaseDiagnosticsFeature(host);
return {
host,
view,
initialise,
unload,
commands,
listeners,
settings,
openDialog,
unsubscribe,
close: () => close(),
setReady: (value: boolean) => {
ready = value;
},
};
}
describe("database diagnostics serviceFeature", () => {
it("retains the existing Advanced-mode Usage command gate in its new owner", async () => {
const data = fixture();
for (const initialise of data.initialise) await initialise();
const usage = data.commands.find((command) => command.id === "analyse-database")!;
const integrity = data.commands.find((command) => command.id === "inspect-database-integrity")!;
expect(usage.checkCallback(true)).toBe(false);
expect(integrity.checkCallback(true)).toBe(true);
data.settings.useAdvancedMode = true;
expect(usage.checkCallback(true)).toBe(true);
data.setReady(false);
expect(usage.checkCallback(true)).toBe(false);
expect(integrity.checkCallback(true)).toBe(false);
expect(data.openDialog).not.toHaveBeenCalled();
});
it("uses the existing dialogue manager and performs no analysis until the run choice is submitted", async () => {
vi.mocked(runDatabaseDiagnostic).mockClear();
const data = fixture();
const opened = data.view.open("usage");
expect(data.openDialog).toHaveBeenCalledTimes(1);
expect(runDatabaseDiagnostic).not.toHaveBeenCalled();
const options = data.openDialog.mock.calls[0][1];
expect(Object.keys(options).sort()).toEqual(["cancel", "kind", "run"]);
await options.run("original", () => {});
expect(runDatabaseDiagnostic).toHaveBeenCalledWith(
data.host,
"usage",
"original",
expect.objectContaining({ signal: expect.any(AbortSignal) })
);
const signal = vi.mocked(runDatabaseDiagnostic).mock.calls.slice(-1)[0][3]!.signal!;
await data.view.open("integrity");
expect(data.openDialog).toHaveBeenCalledTimes(1);
data.close();
await opened;
expect(signal.aborted).toBe(true);
});
it("wires both Hatch actions and releases its listeners and active request on unload", async () => {
const data = fixture();
expect(data.listeners.has(EVENT_ANALYSE_DB_USAGE)).toBe(true);
expect(data.listeners.has(EVENT_INSPECT_DB_INTEGRITY)).toBe(true);
const opened = data.view.open("integrity");
await data.openDialog.mock.calls[0][1].run("pseudonym", () => {});
const signal = vi.mocked(runDatabaseDiagnostic).mock.calls.slice(-1)[0][3]!.signal!;
for (const unload of data.unload) await unload();
expect(signal.aborted).toBe(true);
expect(data.listeners.size).toBe(0);
expect(data.unsubscribe).toHaveBeenCalledTimes(2);
data.close();
await opened;
});
});