Polish diagnostic reporting and local pseudonym follow-up

This commit is contained in:
vorotamoroz committed 2026-10-20 08:23:56 +00:00
1 parent c1ed71f686
commit 32874ea97f
21 files changed
+2425 -154

No files matched your search

@@ -1,9 +1,17 @@
import type { DiagnosticNameMode } from "@vrtmrz/livesync-commonlib/diagnostics";
import type { DatabaseDiagnosticKind, DatabaseDiagnosticOutput } from "./databaseDiagnostics";
import type {
DatabaseDiagnosticKind,
DatabaseDiagnosticOutput,
DatabaseDiagnosticProgress,
} 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>;
run(
nameMode: DiagnosticNameMode,
onProgress: (progress: DatabaseDiagnosticProgress) => void,
includeRemote?: boolean
): Promise<DatabaseDiagnosticOutput>;
cancel(): void;
}
+75 -13
View File
@@ -17,6 +17,7 @@ import { REMOTE_COUCHDB, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-
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";
import { LOG_LEVEL_INFO, Logger } from "octagonal-wheels/common/logger";
export type DatabaseDiagnosticKind = "usage" | "integrity";
export type DatabaseDiagnosticHost = NecessaryServicesInterfaces<
@@ -36,6 +37,15 @@ export interface DatabaseDiagnosticOutput {
artifacts: DatabaseDiagnosticArtifact[];
}
export interface DatabaseDiagnosticProgress {
phase: "usage" | "accounting" | "metadata" | "content";
source?: "local" | "remote";
processed: number;
total?: number;
totalIsFinal?: boolean;
elapsedMs: number;
}
export async function readDiagnosticStorage(
storage: Pick<StorageAccess, "statHidden" | "readHiddenFileBinary">,
path: string
@@ -59,7 +69,7 @@ export async function readDiagnosticStorage(
) {
return { status: "unreadable", reason: "storage-changed" };
}
return { status: "available", bytes };
return { status: "available", bytes, mtime: after.mtime };
} catch {
return { status: "unreadable", reason: "content-unreadable" };
}
@@ -70,8 +80,13 @@ export async function runDatabaseDiagnostic(
host: DatabaseDiagnosticHost,
kind: DatabaseDiagnosticKind,
nameMode: DiagnosticNameMode,
options: { signal?: AbortSignal; onProgress?(processed: number): void } = {}
options: {
signal?: AbortSignal;
onProgress?(progress: DatabaseDiagnosticProgress): void;
includeRemote?: boolean;
} = {}
): Promise<DatabaseDiagnosticOutput> {
const started = performance.now();
const configuration = JSON.stringify(host.services.setting.currentSettings());
const settings = JSON.parse(configuration) as ObsidianLiveSyncSettings;
const database = host.services.database.localDatabase.localDatabase;
@@ -79,6 +94,12 @@ export async function runDatabaseDiagnostic(
JSON.stringify(host.services.setting.currentSettings()) === configuration &&
host.services.database.localDatabase.localDatabase === database;
const mapper = createDiagnosticNameMapper(nameMode);
const httpRequestCounters = { httpRequests: 0, httpFailedRequests: 0, httpRequestDurationMs: 0 };
const getHttpRequestCounters = () => ({
httpRequests: httpRequestCounters.httpRequests,
httpFailedRequests: httpRequestCounters.httpFailedRequests,
httpRequestDurationMs: Math.round(httpRequestCounters.httpRequestDurationMs),
});
const versions = {
commonlib: commonlibPackage.version,
plugin: host.services.API.getPluginVersion(),
@@ -89,7 +110,14 @@ export async function runDatabaseDiagnostic(
const result = await analyseDatabaseUsage({
source: local,
signal: options.signal,
onProgress: (progress) => options.onProgress?.(progress.revisionsProcessed),
onProgress: (progress) =>
options.onProgress?.({
phase: progress.phase === "accounting" ? "accounting" : "usage",
processed: progress.processed,
total: progress.total,
totalIsFinal: progress.totalIsFinal,
elapsedMs: Math.round(performance.now() - started),
}),
});
if (!isConfigurationCurrent()) {
result.errors.push({ code: "configuration_changed" });
@@ -110,23 +138,41 @@ export async function runDatabaseDiagnostic(
}
return { status: result.status, nameMode, artifacts };
}
const result = await analyseDatabaseIntegrity({
const includeRemote = options.includeRemote !== false;
const integrityOptions: Parameters<typeof analyseDatabaseIntegrity>[0] = {
local,
remote:
settings.remoteType === REMOTE_COUCHDB
includeRemote && 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),
fetch: async (request, requestOptions) => {
const requestStarted = performance.now();
httpRequestCounters.httpRequests++;
try {
const response = settings.useRequestAPI
? await host.services.API.nativeFetch(request, requestOptions)
: await host.services.API.webCompatFetch(request, requestOptions);
if (!response.ok) httpRequestCounters.httpFailedRequests++;
return response;
} catch (error) {
httpRequestCounters.httpFailedRequests++;
throw error;
} finally {
httpRequestCounters.httpRequestDurationMs += performance.now() - requestStarted;
}
},
})
)
: undefined,
remoteUnavailableReason: settings.remoteType === REMOTE_COUCHDB ? "source-unavailable" : "unsupported-remote",
remoteUnavailableReason: includeRemote
? settings.remoteType === REMOTE_COUCHDB
? "source-unavailable"
: "unsupported-remote"
: undefined,
includeRemote,
path: {
getPath: (entry) => host.services.path.getPath(entry),
path2id: (path, prefix) => host.services.path.path2idWithSettings(path, settings, prefix),
@@ -141,10 +187,17 @@ export async function runDatabaseDiagnostic(
readStorage: (path) => readDiagnosticStorage(host.serviceModules.storageAccess, path),
isConfigurationCurrent,
signal: options.signal,
onProgress: (progress) => options.onProgress?.(progress.processed),
});
onProgress: (progress) => options.onProgress?.(progress),
onLog: (event) =>
Logger(
`Database integrity inspection: ${JSON.stringify({ ...event, ...getHttpRequestCounters() })}`,
LOG_LEVEL_INFO
),
};
const result = await analyseDatabaseIntegrity(integrityOptions);
const formattingStarted = performance.now();
const report = projectIntegrityReport(result, mapper, versions);
return {
const output: DatabaseDiagnosticOutput = {
status: result.status,
nameMode,
artifacts: [
@@ -160,4 +213,13 @@ export async function runDatabaseDiagnostic(
},
],
};
const requestCounters = getHttpRequestCounters();
Logger(
`Database integrity report: status=${result.status}, observations=${result.observations.length}, ` +
`httpRequests=${requestCounters.httpRequests}, httpFailedRequests=${requestCounters.httpFailedRequests}, ` +
`httpRequestDurationMs=${requestCounters.httpRequestDurationMs}, ` +
`formattingMs=${Math.round(performance.now() - formattingStarted)}, elapsedMs=${Math.round(performance.now() - started)}`,
LOG_LEVEL_INFO
);
return output;
}
@@ -1,12 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_P2P,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
const diagnosticsMocks = vi.hoisted(() => ({ analyseOptions: vi.fn() }));
vi.mock("@vrtmrz/livesync-commonlib/diagnostics", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/diagnostics")>();
return {
...actual,
analyseDatabaseIntegrity: (options: Parameters<typeof actual.analyseDatabaseIntegrity>[0]) => {
diagnosticsMocks.analyseOptions(options);
return actual.analyseDatabaseIntegrity(options);
},
};
});
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 = {
const settings: ObsidianLiveSyncSettings = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_P2P,
readChunksOnline: true,
@@ -81,6 +100,41 @@ function fixture() {
}
describe("database diagnostic host composition", () => {
it("passes growing Usage totals and the separate accounting phase to the dialogue", async () => {
const onProgress = vi.fn();
await runDatabaseDiagnostic(fixture().host, "usage", "pseudonym", { onProgress });
const progress = onProgress.mock.calls.map(([value]) => value);
expect(progress).toContainEqual(
expect.objectContaining({
phase: "usage",
processed: 0,
total: 0,
totalIsFinal: false,
})
);
expect(progress).toContainEqual(
expect.objectContaining({
phase: "usage",
processed: 2,
total: 2,
totalIsFinal: true,
})
);
const accounting = progress.filter((value) => value.phase === "accounting");
expect(accounting.length).toBeGreaterThan(1);
const final = accounting[accounting.length - 1];
expect(final).toMatchObject({ processed: final.total, totalIsFinal: true });
});
it("preserves phase, source, and totals for the inspection dialogue", async () => {
const onProgress = vi.fn();
await runDatabaseDiagnostic(fixture().host, "integrity", "pseudonym", { onProgress });
expect(onProgress).toHaveBeenCalledWith(
expect.objectContaining({ phase: "metadata", source: "local", processed: 0 })
);
expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: "content", processed: 1, total: 1 }));
});
it("keeps on-demand fetching enabled while analysing only existing local revisions", async () => {
const data = fixture();
const before = JSON.stringify(data.settings);
@@ -113,6 +167,9 @@ describe("database diagnostic host composition", () => {
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(diagnosticsMocks.analyseOptions).toHaveBeenLastCalledWith(
expect.objectContaining({ includeRemote: true })
);
expect(result.status).toBe("partial");
expect(data.fetch).not.toHaveBeenCalled();
expect(data.put).not.toHaveBeenCalled();
@@ -135,6 +192,82 @@ describe("database diagnostic host composition", () => {
}
});
it("excludes the remote source for a local integrity run", async () => {
const data = fixture();
data.settings.remoteType = REMOTE_COUCHDB;
data.settings.couchDB_DBNAME = "private-database";
data.settings.useRequestAPI = true;
data.fetch
.mockImplementationOnce(async () => {
await new Promise((resolve) => setTimeout(resolve, 2));
return { ok: false, status: 503 } as Response;
})
.mockRejectedValue(new Error("simulated diagnostic request failure"));
diagnosticsMocks.analyseOptions.mockClear();
const result = await runDatabaseDiagnostic(data.host, "integrity", "pseudonym", {
includeRemote: false,
});
expect(data.fetch).not.toHaveBeenCalled();
const passedOptions = diagnosticsMocks.analyseOptions.mock.calls[0][0];
expect(passedOptions.includeRemote).toBe(false);
expect(passedOptions.remote).toBeUndefined();
expect(result.artifacts.map((artifact) => artifact.format)).toEqual(["markdown", "json"]);
const report = JSON.parse(result.artifacts.find((artifact) => artifact.format === "json")!.content);
expect(report.scope.remote).toBe("excluded");
expect(report.coverage.remote.status).toBe("not-checked");
expect(report.errors.some((error: { source: string }) => error.source === "remote")).toBe(false);
});
it("logs cumulative HTTP dispatch, failure, and duration counters for remote inspection", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const nonOk = fixture();
nonOk.settings.remoteType = REMOTE_COUCHDB;
nonOk.settings.couchDB_DBNAME = "private-database";
nonOk.settings.useRequestAPI = true;
nonOk.fetch.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 2));
return { ok: false, status: 503 } as Response;
});
await runDatabaseDiagnostic(nonOk.host, "integrity", "pseudonym");
const thrown = fixture();
thrown.settings.remoteType = REMOTE_COUCHDB;
thrown.settings.couchDB_DBNAME = "private-database";
thrown.settings.useRequestAPI = true;
thrown.fetch.mockRejectedValue(new Error("simulated diagnostic request failure"));
await runDatabaseDiagnostic(thrown.host, "integrity", "pseudonym");
const messages = log.mock.calls.map(([message]) => String(message));
const inspectionLogs = messages.filter((message) => message.includes("Database integrity inspection:"));
expect(inspectionLogs.length).toBeGreaterThan(0);
const inspectionEvents = inspectionLogs.map((message) => {
const json = message.slice(message.indexOf("{"));
return JSON.parse(json);
});
expect(inspectionEvents.some((event) => event.httpRequests > 0)).toBe(true);
expect(inspectionEvents.some((event) => event.httpFailedRequests > 0)).toBe(true);
expect(inspectionEvents.every((event) => typeof event.httpRequestDurationMs === "number")).toBe(true);
const summaries = messages.filter((message) => message.includes("Database integrity report:"));
expect(summaries).toHaveLength(2);
for (const [index, summary] of summaries.entries()) {
const match = /httpRequests=(\d+), httpFailedRequests=(\d+), httpRequestDurationMs=(\d+)/.exec(summary);
expect(match).not.toBeNull();
const data = index === 0 ? nonOk : thrown;
expect(Number(match![1])).toBe(data.fetch.mock.calls.length);
expect(Number(match![2])).toBe(data.fetch.mock.calls.length);
expect(Number(match![3])).toBeGreaterThanOrEqual(0);
if (index === 0) expect(Number(match![3])).toBeGreaterThan(0);
}
} finally {
log.mockRestore();
}
});
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");
@@ -172,6 +305,17 @@ describe("database diagnostic host composition", () => {
});
describe("diagnostic Vault reads", () => {
it("reuses the validated snapshot timestamp without adding stat or content reads", async () => {
const data = fixture();
expect(await readDiagnosticStorage(data.storage, data.path)).toEqual({
status: "available",
bytes: data.bytes,
mtime: 1,
});
expect(data.storage.statHidden).toHaveBeenCalledTimes(2);
expect(data.storage.readHiddenFileBinary).toHaveBeenCalledTimes(1);
});
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);
+346
View File
@@ -0,0 +1,346 @@
import { $msg } from "@/common/translation";
import { delay } from "octagonal-wheels/promises";
import { createDiagnosticNameMapper, parseDiagnosticPathToken } from "@vrtmrz/livesync-commonlib/diagnostics";
import { EntryTypes, type FilePath, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ICHeader, ICXHeader, PSCHeader } from "@vrtmrz/livesync-commonlib/compat/common/models/fileaccess.const";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import type { ComponentHasResult } from "@/modules/services/LiveSyncUI/svelteDialog";
import DiagnosticFileLookupDialog from "@/modules/services/LiveSyncUI/dialogues/DiagnosticFileLookupDialog.svelte";
import type {
FileDatabaseInfo,
FileDatabaseInfoCore,
FileDatabaseMergeBaseInfo,
InspectFileDatabaseInfoOptions,
RevisionDatabaseInfo,
} from "./fileDatabaseInfo";
import { inspectFileDatabaseInfo } from "./fileDatabaseInfo";
import type {
DiagnosticFileCandidate,
DiagnosticFileLookupDialogOptions,
DiagnosticFileLookupResult,
FindDiagnosticFilesOptions,
} from "./diagnosticFileLookupTypes";
import type { DatabaseDiagnosticArtifact, DatabaseDiagnosticOutput } from "./databaseDiagnostics";
type CurrentFileMetadata = {
_id: string;
path: FilePathWithPrefix | FilePath;
type: string;
_deleted?: boolean;
};
type FileLookupDialogManager = {
open<TResult, TInitial = TResult>(
component: ComponentHasResult<TResult, TInitial>,
initialData?: TInitial
): Promise<TResult | undefined>;
};
type FileLookupUI = FileDatabaseInfoCore["services"]["UI"] & {
dialogManager: FileLookupDialogManager;
};
function isCurrentFileMetadata(value: unknown): value is CurrentFileMetadata {
if (value === null || typeof value !== "object") return false;
const entry = value as Record<string, unknown>;
if (
typeof entry._id !== "string" ||
typeof entry.path !== "string" ||
(entry.type !== EntryTypes.NOTE_BINARY && entry.type !== EntryTypes.NOTE_PLAIN) ||
entry._deleted === true
) {
return false;
}
const path = entry.path;
return ![ICHeader, ICXHeader, PSCHeader].some((prefix) => path.startsWith(prefix));
}
function cancelledResult(processed: number, candidates: DiagnosticFileCandidate[]): DiagnosticFileLookupResult {
return { valid: true, status: "cancelled", processed, candidates };
}
async function yieldToEventLoop(): Promise<void> {
await delay(0);
}
export async function findDiagnosticFiles(
core: FileDatabaseInfoCore,
token: string,
options: FindDiagnosticFilesOptions = {}
): Promise<DiagnosticFileLookupResult> {
const parsedToken = parseDiagnosticPathToken(token);
if (!parsedToken) {
return { valid: false, status: "invalid", processed: 0, candidates: [] };
}
const mapper = createDiagnosticNameMapper("pseudonym", parsedToken.salt);
const candidates: DiagnosticFileCandidate[] = [];
let processed = 0;
const reportProgress = () => options.onProgress?.(processed);
reportProgress();
if (options.signal?.aborted) {
reportProgress();
return cancelledResult(processed, candidates);
}
const vaultPaths = new Set(await core.storageAccess.getFileNames());
const matchingMetadataPaths = new Set<string>();
if (options.signal?.aborted) {
reportProgress();
return cancelledResult(processed, candidates);
}
for await (const entry of core.localDatabase.findAllDocs()) {
processed++;
if (isCurrentFileMetadata(entry)) {
const databasePath = entry.path;
const path = stripAllPrefixes(databasePath);
if (mapper.path(path) === token) {
matchingMetadataPaths.add(path);
candidates.push({
path,
databasePath,
documentId: entry._id,
storageExists: vaultPaths.has(path),
source: "metadata",
});
}
}
if (processed % 128 === 0) {
reportProgress();
await yieldToEventLoop();
if (options.signal?.aborted) {
reportProgress();
return cancelledResult(processed, candidates);
}
}
}
if (options.signal?.aborted) {
reportProgress();
return cancelledResult(processed, candidates);
}
for (const path of vaultPaths) {
processed++;
if (mapper.path(path) === token && !matchingMetadataPaths.has(path)) {
candidates.push({ path, storageExists: true, source: "vault" });
}
if (processed % 128 === 0) {
reportProgress();
await yieldToEventLoop();
if (options.signal?.aborted) {
reportProgress();
return cancelledResult(processed, candidates);
}
}
}
reportProgress();
if (options.signal?.aborted) return cancelledResult(processed, candidates);
return { valid: true, status: "complete", processed, candidates };
}
function finiteMetric(value: number | undefined, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function nonnegativeMetric(value: number | undefined): number {
return Math.max(0, finiteMetric(value));
}
function revisionStatus(value: string): "available" | "missing" | "unknown" {
if (value === "available" || value === "missing") return value;
return "unknown";
}
function storageType(value: string): "absent" | "notes" | "newnote" | "plain" | "unknown" {
if (value === "absent" || value === "notes" || value === "newnote" || value === "plain") return value;
return "unknown";
}
function localChunkState(value: string): "available" | "deleted" | "missing" | "unknown" {
if (value === "available" || value === "deleted" || value === "missing") return value;
return "unknown";
}
function projectRevisionInfo(info: RevisionDatabaseInfo, mapper: ReturnType<typeof createDiagnosticNameMapper>) {
return {
documentId: mapper.id(info.documentId),
revision: info.revision === null ? null : mapper.revision(info.revision),
current: info.current,
deleted: info.deleted,
storageType: storageType(info.storageType),
storageLayout: info.storageLayout === "legacy-inline" ? "legacy-inline" : "chunked",
ctime: finiteMetric(info.ctime),
mtime: finiteMetric(info.mtime),
recordedSize: nonnegativeMetric(info.recordedSize),
revisionHistory: info.revisionHistory.map(({ revision, status }) => ({
revision: mapper.revision(revision),
status: revisionStatus(status),
})),
chunkReferences: nonnegativeMetric(info.chunkReferences),
uniqueChunkReferences: nonnegativeMetric(info.uniqueChunkReferences),
embeddedChunkReferences: nonnegativeMetric(info.embeddedChunkReferences),
locallyStoredChunkReferences: nonnegativeMetric(info.locallyStoredChunkReferences),
contentAvailableLocally: info.contentAvailableLocally,
chunks: info.chunks.map((chunk) => ({
id: mapper.chunk(chunk.id),
referenceCount: nonnegativeMetric(chunk.referenceCount),
embedded: chunk.embedded,
storedInLocalDatabase: chunk.storedInLocalDatabase,
localDatabaseState: localChunkState(chunk.localDatabaseState),
localDatabaseRevision:
chunk.localDatabaseRevision === null ? null : mapper.revision(chunk.localDatabaseRevision),
})),
};
}
function projectMergeBase(info: FileDatabaseMergeBaseInfo, mapper: ReturnType<typeof createDiagnosticNameMapper>) {
return {
winnerRevision: mapper.revision(info.winnerRevision),
conflictRevision: mapper.revision(info.conflictRevision),
revision: info.revision === null ? null : mapper.revision(info.revision),
metadataAvailableLocally: info.metadataAvailableLocally,
contentAvailableLocally: info.contentAvailableLocally,
missingChunkIds: info.missingChunkIds.map((id) => mapper.chunk(id)),
unavailableSharedRevisions: info.unavailableSharedRevisions.map((revision) => mapper.revision(revision)),
};
}
function projectStorage(info: FileDatabaseInfo["storage"]) {
return {
exists: info.exists,
...(info.ctime === undefined ? {} : { ctime: finiteMetric(info.ctime) }),
...(info.mtime === undefined ? {} : { mtime: finiteMetric(info.mtime) }),
...(info.size === undefined ? {} : { size: nonnegativeMetric(info.size) }),
};
}
function projectFileInfo(
info: FileDatabaseInfo,
source: DiagnosticFileCandidate["source"],
mapper: ReturnType<typeof createDiagnosticNameMapper>
) {
return {
path: mapper.path(info.path),
databasePath: mapper.path(info.databasePath),
source,
storage: projectStorage(info.storage),
database: {
source: "local database on this device" as const,
remoteQueried: false as const,
inspected: true,
exists: info.database.exists,
...(info.database.documentId === undefined ? {} : { documentId: mapper.id(info.database.documentId) }),
currentRevision:
info.database.currentRevision === null ? null : mapper.revision(info.database.currentRevision),
conflictCount: nonnegativeMetric(info.database.conflictCount),
conflictRevisions: info.database.conflictRevisions.map((revision) => mapper.revision(revision)),
unavailableConflictRevisions: info.database.unavailableConflictRevisions.map((revision) =>
mapper.revision(revision)
),
revisions: info.database.revisions.map((revision) => projectRevisionInfo(revision, mapper)),
mergeBases: info.database.mergeBases.map((mergeBase) => projectMergeBase(mergeBase, mapper)),
},
};
}
async function inspectVaultOnlyCandidate(
core: FileDatabaseInfoCore,
candidate: DiagnosticFileCandidate,
mapper: ReturnType<typeof createDiagnosticNameMapper>,
signal?: AbortSignal
) {
const exists = await core.storageAccess.isExistsIncludeHidden(candidate.path);
if (signal?.aborted) throw new DOMException("File inspection cancelled", "AbortError");
const stat = exists ? await core.storageAccess.statHidden(candidate.path) : null;
return {
path: mapper.path(candidate.path),
source: "vault" as const,
storage: projectStorage(
stat ? { exists: true, ctime: stat.ctime, mtime: stat.mtime, size: stat.size } : { exists: false }
),
database: {
source: "local database on this device" as const,
remoteQueried: false as const,
inspected: false,
reason: "no-local-target" as const,
},
};
}
export async function buildPseudonymousFileReport(
core: FileDatabaseInfoCore,
candidate: DiagnosticFileCandidate,
salt: string,
signal?: AbortSignal
): Promise<DatabaseDiagnosticOutput> {
if (signal?.aborted) throw new DOMException("File inspection cancelled", "AbortError");
const mapper = createDiagnosticNameMapper("pseudonym", salt);
if (!mapper.salt) throw new Error("Invalid diagnostic path token");
if (candidate.source === "metadata" && candidate.documentId === undefined) {
throw new Error("A Metadata candidate requires its exact document ID");
}
const info =
candidate.source === "metadata" && candidate.documentId !== undefined
? await inspectFileDatabaseInfo(core, candidate.path, {
documentId: candidate.documentId,
signal,
...(candidate.databasePath === undefined ? {} : { databasePath: candidate.databasePath }),
} satisfies InspectFileDatabaseInfoOptions)
: undefined;
const file = info
? projectFileInfo(info, candidate.source, mapper)
: await inspectVaultOnlyCandidate(core, candidate, mapper, signal);
if (signal?.aborted) throw new DOMException("File inspection cancelled", "AbortError");
const report = {
schemaVersion: 1,
kind: "file",
status: "complete",
nameMode: "pseudonym",
salt: mapper.salt,
scope: {
source: "local database on this device",
remoteQueried: false,
vaultContentRead: false,
fileContents: "omitted",
revisions: "current revision, current conflict revisions, and selected merge-base revisions",
revisionHistory: "status metadata is listed for current and conflict revisions where available",
historicalFileBodiesScanned: false,
},
file,
};
const json = JSON.stringify(report, null, 2);
const markdown = `<details>\n<summary>${$msg("Local file diagnostic report")}</summary>\n\n\`\`\`json\n${json}\n\`\`\`\n</details>`;
const artifacts: DatabaseDiagnosticArtifact[] = [
{ format: "json", filename: "diagnostic-file-report.json", content: json },
{ format: "markdown", filename: "diagnostic-file-report.md", content: markdown },
];
return { status: "complete", nameMode: "pseudonym", artifacts };
}
export async function showDiagnosticFileLookup(
core: FileDatabaseInfoCore,
openFile?: (path: string) => Promise<void> | void
): Promise<void> {
let activeController: AbortController | undefined;
const options: DiagnosticFileLookupDialogOptions = {
find: async (token, onProgress) => {
activeController?.abort();
activeController = new AbortController();
return await findDiagnosticFiles(core, token, { signal: activeController.signal, onProgress });
},
prepareReport: async (candidate, token) => {
const parsed = parseDiagnosticPathToken(token);
if (!parsed) throw new Error("Invalid diagnostic path token");
activeController?.abort();
activeController = new AbortController();
return await buildPseudonymousFileReport(core, candidate, parsed.salt, activeController.signal);
},
openFile,
cancel: () => activeController?.abort(),
};
const ui = core.services.UI as FileLookupUI;
try {
await ui.dialogManager.open(DiagnosticFileLookupDialog, options);
} finally {
activeController?.abort();
}
}
@@ -0,0 +1,362 @@
import { createDiagnosticNameMapper } from "@vrtmrz/livesync-commonlib/diagnostics";
import { describe, expect, it, vi } from "vitest";
import { buildPseudonymousFileReport, findDiagnosticFiles, showDiagnosticFileLookup } from "./diagnosticFileLookup";
import type { DiagnosticFileLookupDialogOptions } from "./diagnosticFileLookupTypes";
type TestDocument = {
_id: string;
_rev?: string;
_conflicts?: string[];
_revs_info?: Array<{ rev: string; status: string }>;
_deleted?: boolean;
children?: string[];
ctime?: number;
data?: string;
deleted?: boolean;
eden?: Record<string, unknown>;
mtime?: number;
path?: string;
size?: number;
type?: string;
};
async function* entries(documents: TestDocument[]) {
for (const document of documents) yield document;
}
function createCore(vaultPaths: string[] = [], documents: TestDocument[] = []) {
const existingPaths = new Set(vaultPaths);
const chunkRevisions = new Map([["h:secret-chunk", "1-secret-chunk-revision"]]);
const core = {
settings: {},
storageAccess: {
getFileNames: vi.fn(async () => [...vaultPaths]),
getFilesIncludeHidden: vi.fn(async () => [...vaultPaths]),
isExistsIncludeHidden: vi.fn(async (path: string) => existingPaths.has(path)),
statHidden: vi.fn(async (path: string) =>
existingPaths.has(path) ? { ctime: 10, mtime: 20, size: 30, type: "file" } : undefined
),
},
localDatabase: {
allDocsRaw: vi.fn(async ({ keys }: { keys: string[] }) => ({
rows: keys.flatMap((key) => {
const rev = chunkRevisions.get(key);
return rev ? [{ id: key, key, value: { rev } }] : [];
}),
})),
findAllDocs: vi.fn(() => entries(documents)),
getDBEntryFromMeta: vi.fn(),
getDBEntry: vi.fn(),
localDatabase: {
get: vi.fn(async (id: string) => {
const document = documents.find(({ _id }) => _id === id);
if (document) return document;
throw Object.assign(new Error("missing"), { status: 404 });
}),
},
},
services: {
path: { path2id: vi.fn(async () => "f:path-derived") },
UI: {
promptCopyToClipboard: vi.fn(async () => true),
dialogManager: { open: vi.fn() },
},
},
};
return core;
}
function diagnosticPath(path: string, salt = "salt1234"): string {
return createDiagnosticNameMapper("pseudonym", salt).path(path);
}
describe("diagnostic file lookup", () => {
it.each(["metadata", "chunks"] as const)(
"stops follow-up reads when the dialogue closes during a %s read",
async (pendingRead) => {
const path = "cancelled.md";
const selected = {
_id: "f:cancelled",
_rev: "3-current",
_conflicts: ["3-conflict"],
path,
type: "plain",
children: ["h:secret-chunk"],
};
const core = createCore([path], [selected]);
let markStarted!: () => void;
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
let releaseRead!: () => void;
const pending = new Promise<void>((resolve) => {
releaseRead = resolve;
});
if (pendingRead === "metadata") {
core.localDatabase.localDatabase.get.mockImplementationOnce(async () => {
markStarted();
await pending;
return selected;
});
} else {
core.localDatabase.allDocsRaw.mockImplementationOnce(async () => {
markStarted();
await pending;
return { rows: [] };
});
}
core.services.UI.dialogManager.open.mockImplementation(async (...args: unknown[]) => {
const options = args[1] as DiagnosticFileLookupDialogOptions;
const preparation = options.prepareReport(
{ path, documentId: selected._id, storageExists: true, source: "metadata" },
diagnosticPath(path)
);
await started;
const cancelled = expect(preparation).rejects.toMatchObject({ name: "AbortError" });
options.cancel();
releaseRead();
await cancelled;
});
await showDiagnosticFileLookup(core as never);
expect(core.localDatabase.localDatabase.get).toHaveBeenCalledTimes(1);
expect(core.localDatabase.allDocsRaw).toHaveBeenCalledTimes(pendingRead === "metadata" ? 0 : 1);
}
);
it("rejects an invalid path token before reading storage or the database", async () => {
const core = createCore(["note.md"], [{ _id: "f:note", path: "note.md", type: "plain" }]);
await expect(findDiagnosticFiles(core as never, "not-a-path-token")).resolves.toEqual({
valid: false,
status: "invalid",
processed: 0,
candidates: [],
});
expect(core.storageAccess.getFileNames).not.toHaveBeenCalled();
expect(core.localDatabase.findAllDocs).not.toHaveBeenCalled();
expect(core.localDatabase.localDatabase.get).not.toHaveBeenCalled();
});
it("matches path spelling exactly without case folding or Unicode normalisation", async () => {
const decomposed = "Cafe\u0301.md";
const core = createCore(
[decomposed, "Café.md", "CAFÉ.md"],
[
{ _id: "f:decomposed", path: decomposed, type: "plain" },
{ _id: "f:composed", path: "Café.md", type: "plain" },
{ _id: "f:case", path: "CAFÉ.md", type: "plain" },
]
);
const progressCounts: number[] = [];
const result = await findDiagnosticFiles(core as never, diagnosticPath(decomposed), {
onProgress: (processed) => progressCounts.push(processed),
});
expect(result).toMatchObject({ valid: true, status: "complete" });
expect(result.candidates).toEqual([
expect.objectContaining({
path: decomposed,
documentId: "f:decomposed",
source: "metadata",
storageExists: true,
}),
]);
expect(core.localDatabase.localDatabase.get).not.toHaveBeenCalled();
expect(progressCounts).toEqual([0, 6]);
});
it("keeps distinct Metadata documents with the same exact path as separate candidates", async () => {
const path = "duplicate.md";
const core = createCore(
[path],
[
{ _id: "f:duplicate-one", path, type: "plain" },
{ _id: "f:duplicate-two", path, type: "plain" },
]
);
const result = await findDiagnosticFiles(core as never, diagnosticPath(path));
expect(result.candidates).toEqual([
expect.objectContaining({ documentId: "f:duplicate-one", path, storageExists: true, source: "metadata" }),
expect.objectContaining({ documentId: "f:duplicate-two", path, storageExists: true, source: "metadata" }),
]);
expect(result.candidates).toHaveLength(2);
});
it("keeps logically deleted current file Metadata as an exact candidate", async () => {
const path = "vault-only.md";
const core = createCore(
[path],
[
{ _id: "f:deleted", path, type: "plain", deleted: true },
{ _id: "f:chunk", path, type: "chunk" },
{ _id: "i:internal", path: "i:.obsidian/config.json", type: "plain" },
]
);
const result = await findDiagnosticFiles(core as never, diagnosticPath(path));
expect(result.candidates).toEqual([
expect.objectContaining({ path, documentId: "f:deleted", storageExists: true, source: "metadata" }),
]);
});
it("returns a Vault-only candidate when there is no current normal-file Metadata", async () => {
const path = "vault-only.md";
const core = createCore(
[path],
[
{ _id: "f:chunk", path, type: "chunk" },
{ _id: "f:legacy", path, type: "notes" },
{ _id: "i:internal", path: "i:.obsidian/config.json", type: "plain" },
]
);
const result = await findDiagnosticFiles(core as never, diagnosticPath(path));
expect(result.candidates).toEqual([expect.objectContaining({ path, storageExists: true, source: "vault" })]);
expect(result.candidates[0]).not.toHaveProperty("documentId");
const report = await buildPseudonymousFileReport(core as never, result.candidates[0], "salt1234");
const jsonText = report.artifacts.find(({ format }) => format === "json")!.content;
const json = JSON.parse(jsonText);
expect(json.file.path).toBe(diagnosticPath(path));
expect(jsonText).not.toContain(path);
expect(json.file.database).toEqual({
source: "local database on this device",
remoteQueried: false,
inspected: false,
reason: "no-local-target",
});
expect(json.scope).toEqual({
source: "local database on this device",
remoteQueried: false,
vaultContentRead: false,
fileContents: "omitted",
revisions: "current revision, current conflict revisions, and selected merge-base revisions",
revisionHistory: "status metadata is listed for current and conflict revisions where available",
historicalFileBodiesScanned: false,
});
expect(core.localDatabase.localDatabase.get).not.toHaveBeenCalled();
});
it("returns partial exact matches when cancellation arrives during enumeration", async () => {
const path = "match.md";
const documents = [
{ _id: "f:match", path, type: "plain" },
...Array.from({ length: 299 }, (_, index) => ({
_id: `f:other-${index}`,
path: `other-${index}.md`,
type: "plain",
})),
];
const core = createCore([], documents);
const controller = new AbortController();
const progressCounts: number[] = [];
const result = await findDiagnosticFiles(core as never, diagnosticPath(path), {
signal: controller.signal,
onProgress: (processed) => {
progressCounts.push(processed);
if (processed >= 128) controller.abort();
},
});
expect(result).toMatchObject({ valid: true, status: "cancelled", processed: 128 });
expect(result.candidates).toEqual([
expect.objectContaining({ documentId: "f:match", path, storageExists: false, source: "metadata" }),
]);
expect(progressCounts).toEqual([0, 128, 128]);
});
it("projects an exact target with the token salt, preserves revision generations, and omits raw values", async () => {
const path = "private-name.md";
const salt = "salt1234";
const token = diagnosticPath(path, salt);
const selected = {
_id: "f:selected-private-id",
_rev: "3-private-revision",
_conflicts: [],
_revs_info: [{ rev: "3-private-revision", status: "private-status-value" }],
path,
ctime: 11,
mtime: 22,
size: 33,
type: "plain",
data: "private file contents",
children: ["h:private-chunk-id"],
eden: {},
unreported: "private-extra-value",
};
const decoy = { ...selected, _id: "f:path-derived", path: "different.md", _rev: "9-decoy-revision" };
const core = createCore([path], [selected, decoy]);
core.localDatabase.localDatabase.get.mockImplementation(async (id: string) => {
if (id === selected._id) return selected;
if (id === decoy._id) return decoy;
throw Object.assign(new Error("missing"), { status: 404 });
});
const candidate = {
path,
databasePath: path as never,
documentId: selected._id,
storageExists: true,
source: "metadata" as const,
};
const output = await buildPseudonymousFileReport(core as never, candidate, salt);
const jsonText = output.artifacts.find(({ format }) => format === "json")!.content;
const json = JSON.parse(jsonText);
const revision = json.file.database.revisions[0];
expect(output.nameMode).toBe("pseudonym");
expect(json.kind).toBe("file");
expect(json.nameMode).toBe("pseudonym");
expect(json.salt).toBe(salt);
expect(json.file.path).toBe(token);
expect(json.scope).toMatchObject({ vaultContentRead: false, fileContents: "omitted" });
expect(revision.documentId).toMatch(/^document-[0-9a-f]{32}$/);
expect(revision.revision).toMatch(/^3-psu[0-9a-f]{32}$/);
expect(revision.chunks[0].id).toMatch(/^chunk-[0-9a-f]{32}$/);
expect(revision.revisionHistory[0].status).toBe("unknown");
expect(json.file.database.inspected).toBe(true);
expect(output.artifacts.map(({ format }) => format)).toEqual(["json", "markdown"]);
expect(output.artifacts.find(({ format }) => format === "markdown")?.content).toContain("```json");
expect(jsonText).not.toContain(path);
expect(jsonText).not.toContain(selected._id);
expect(jsonText).not.toContain("3-private-revision");
expect(jsonText).not.toContain("h:private-chunk-id");
expect(jsonText).not.toContain("private-status-value");
expect(jsonText).not.toContain("private file contents");
expect(jsonText).not.toContain("private-extra-value");
expect(core.localDatabase.localDatabase.get.mock.calls.map(([id]) => id)).toEqual([selected._id]);
expect(core.services.path.path2id).not.toHaveBeenCalled();
});
it("keeps an exact Metadata target visibly missing without substituting a path-derived document", async () => {
const path = "vanished.md";
const salt = "salt1234";
const selected = { _id: "f:vanished", path, type: "plain" };
const decoy = { _id: "f:path-derived", path: "wrong.md", type: "plain", _rev: "1-decoy" };
const core = createCore([path], [selected, decoy]);
const lookup = await findDiagnosticFiles(core as never, diagnosticPath(path, salt));
core.localDatabase.localDatabase.get.mockImplementation(async (id: string) => {
if (id === decoy._id) return decoy;
throw Object.assign(new Error("missing"), { status: 404 });
});
const output = await buildPseudonymousFileReport(core as never, lookup.candidates[0], salt);
const jsonText = output.artifacts.find(({ format }) => format === "json")!.content;
const json = JSON.parse(jsonText);
expect(json.file.database).toMatchObject({ inspected: true, exists: false });
expect(json.file.database.documentId).toMatch(/^document-[0-9a-f]{32}$/);
expect(jsonText).not.toContain("f:vanished");
expect(jsonText).not.toContain("f:path-derived");
expect(core.localDatabase.localDatabase.get.mock.calls.map(([id]) => id)).toEqual([selected._id]);
expect(core.services.path.path2id).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,36 @@
import type { FilePath, FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { DatabaseDiagnosticOutput } from "./databaseDiagnostics";
export type DiagnosticFileCandidate = {
path: string;
databasePath?: FilePathWithPrefix | FilePath;
documentId?: string;
storageExists: boolean;
source: "metadata" | "vault";
};
export type DiagnosticFileLookupResult =
| {
valid: false;
status: "invalid";
processed: 0;
candidates: [];
}
| {
valid: true;
status: "cancelled" | "complete";
processed: number;
candidates: DiagnosticFileCandidate[];
};
export interface FindDiagnosticFilesOptions {
signal?: AbortSignal;
onProgress?(processed: number): void;
}
export interface DiagnosticFileLookupDialogOptions {
find(token: string, onProgress: (processed: number) => void): Promise<DiagnosticFileLookupResult>;
prepareReport(candidate: DiagnosticFileCandidate, token: string): Promise<DatabaseDiagnosticOutput>;
openFile?(path: string): Promise<void> | void;
cancel(): void;
}
+64 -25
View File
@@ -91,6 +91,7 @@ export type FileDatabaseInfo = {
source: "local database on this device";
remoteQueried: false;
exists: boolean;
documentId?: string;
currentRevision: string | null;
conflictCount: number;
conflictRevisions: string[];
@@ -100,6 +101,12 @@ export type FileDatabaseInfo = {
};
};
export type InspectFileDatabaseInfoOptions = {
documentId?: string;
databasePath?: FilePathWithPrefix | FilePath;
signal?: AbortSignal;
};
const REPORT_WARNING =
"All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.";
@@ -125,25 +132,35 @@ type RawDatabaseDocument = {
data?: string | string[];
eden?: Record<string, unknown>;
mtime?: number;
path?: FilePathWithPrefix | FilePath;
size?: number;
type?: string;
};
function checkInspectionCancelled(signal?: AbortSignal): void {
if (signal?.aborted) throw new DOMException("File inspection cancelled", "AbortError");
}
async function getLocalDatabaseMeta(
core: FileDatabaseInfoCore,
path: FilePathWithPrefix | FilePath,
options: PouchDB.Core.GetOptions
options: PouchDB.Core.GetOptions,
target?: InspectFileDatabaseInfoOptions
): Promise<DatabaseMeta | false> {
const documentId = await core.services.path.path2id(path);
checkInspectionCancelled(target?.signal);
const documentId = target?.documentId ?? (await core.services.path.path2id(path));
checkInspectionCancelled(target?.signal);
let raw: RawDatabaseDocument;
try {
raw = await core.localDatabase.localDatabase.get(documentId, options);
} catch (error) {
checkInspectionCancelled(target?.signal);
if (isNotFoundError(error)) {
return false;
}
throw error;
}
checkInspectionCancelled(target?.signal);
if (raw.type === "leaf") {
return false;
@@ -156,13 +173,15 @@ async function getLocalDatabaseMeta(
const legacy = rawStorageType === null || rawStorageType === "notes";
const type = legacy ? "notes" : rawStorageType;
const legacyBodyPresent =
legacy && (typeof raw.data === "string" || (Array.isArray(raw.data) && raw.data.every((item) => typeof item === "string")));
legacy &&
(typeof raw.data === "string" ||
(Array.isArray(raw.data) && raw.data.every((item) => typeof item === "string")));
return {
_id: raw._id,
_rev: raw._rev,
_conflicts: raw._conflicts,
_revs_info: raw._revs_info,
path,
path: target ? (raw.path ?? path) : path,
data: legacyBodyPresent ? raw.data : "",
ctime: raw.ctime ?? 0,
mtime: raw.mtime ?? 0,
@@ -180,8 +199,10 @@ async function getLocalDatabaseMeta(
async function collectRevisionDatabaseInfo(
core: FileDatabaseInfoCore,
meta: DatabaseMeta,
current: boolean
current: boolean,
signal?: AbortSignal
): Promise<RevisionDatabaseInfo> {
checkInspectionCancelled(signal);
const legacy = meta._rawStorageType === null || meta._rawStorageType === "notes";
const children = legacy ? [] : "children" in meta ? meta.children : [];
const uniqueChildren = [...new Set(children)];
@@ -201,6 +222,7 @@ async function collectRevisionDatabaseInfo(
include_docs: false,
})
).rows;
checkInspectionCancelled(signal);
const localChunkStates = new Map(
localRows
.filter((row) => "value" in row)
@@ -236,9 +258,7 @@ async function collectRevisionDatabaseInfo(
locallyStoredChunkReferences: children.filter((id) => localChunkStates.get(id)?.state === "available").length,
contentAvailableLocally: legacy
? meta._legacyBodyPresent
: uniqueChildren.every(
(id) => embeddedChildren.has(id) || localChunkStates.get(id)?.state === "available"
),
: uniqueChildren.every((id) => embeddedChildren.has(id) || localChunkStates.get(id)?.state === "available"),
chunks: uniqueChildren.map((id) => {
const localState = localChunkStates.get(id);
return {
@@ -273,15 +293,27 @@ function missingChunkIds(info: RevisionDatabaseInfo): string[] {
.map(({ id }) => id);
}
export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path: string): Promise<FileDatabaseInfo> {
export async function inspectFileDatabaseInfo(
core: FileDatabaseInfoCore,
path: string,
options?: InspectFileDatabaseInfoOptions
): Promise<FileDatabaseInfo> {
checkInspectionCancelled(options?.signal);
const storageExists = await core.storageAccess.isExistsIncludeHidden(path);
checkInspectionCancelled(options?.signal);
const storageStat = storageExists ? await core.storageAccess.statHidden(path) : null;
const databasePath = toDatabasePath(path);
const currentMeta = await getLocalDatabaseMeta(core, databasePath, {
conflicts: true,
revs: true,
revs_info: true,
});
const requestedDatabasePath = options?.databasePath ?? toDatabasePath(path);
const currentMeta = await getLocalDatabaseMeta(
core,
requestedDatabasePath,
{
conflicts: true,
revs: true,
revs_info: true,
},
options
);
const databasePath = options && currentMeta ? currentMeta.path : requestedDatabasePath;
const revisions: RevisionDatabaseInfo[] = [];
const conflictRevisions = currentMeta === false ? [] : (currentMeta._conflicts ?? []);
@@ -292,25 +324,31 @@ export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path:
metadataByRevision.set(currentMeta._rev, currentMeta);
}
const getRevisionMeta = async (revision: string): Promise<DatabaseMeta | false> => {
checkInspectionCancelled(options?.signal);
const cached = metadataByRevision.get(revision);
if (cached !== undefined) {
return cached;
}
const meta = await getLocalDatabaseMeta(core, databasePath, {
rev: revision,
revs: true,
revs_info: true,
});
const meta = await getLocalDatabaseMeta(
core,
requestedDatabasePath,
{
rev: revision,
revs: true,
revs_info: true,
},
options
);
metadataByRevision.set(revision, meta);
return meta;
};
if (currentMeta) {
revisions.push(await collectRevisionDatabaseInfo(core, currentMeta, true));
revisions.push(await collectRevisionDatabaseInfo(core, currentMeta, true, options?.signal));
for (const revision of conflictRevisions) {
const conflictMeta = await getRevisionMeta(revision);
if (conflictMeta) {
revisions.push(await collectRevisionDatabaseInfo(core, conflictMeta, false));
revisions.push(await collectRevisionDatabaseInfo(core, conflictMeta, false, options?.signal));
const winnerHistory = revisionHistory(currentMeta);
const conflictHistory = revisionHistory(conflictMeta);
const conflictHistoryByRevision = new Map(
@@ -323,13 +361,12 @@ export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path:
const unavailableSharedRevisions = sharedHistory
.filter(
({ revision: historyRevision, status }) =>
status !== "available" ||
conflictHistoryByRevision.get(historyRevision) !== "available"
status !== "available" || conflictHistoryByRevision.get(historyRevision) !== "available"
)
.map(({ revision: historyRevision }) => historyRevision);
const sharedMeta = sharedRevision ? await getRevisionMeta(sharedRevision) : false;
const sharedInfo = sharedMeta
? await collectRevisionDatabaseInfo(core, sharedMeta, false)
? await collectRevisionDatabaseInfo(core, sharedMeta, false, options?.signal)
: undefined;
mergeBases.push({
winnerRevision: currentMeta._rev ?? "",
@@ -346,6 +383,7 @@ export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path:
}
}
checkInspectionCancelled(options?.signal);
const report: FileDatabaseInfo = {
path,
databasePath,
@@ -363,6 +401,7 @@ export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path:
source: "local database on this device",
remoteQueried: false,
exists: currentMeta !== false,
...(options?.documentId !== undefined ? { documentId: options.documentId } : {}),
currentRevision: currentMeta ? (currentMeta._rev ?? null) : null,
conflictCount: conflictRevisions.length,
conflictRevisions,
@@ -198,8 +198,10 @@ describe("file database information", () => {
it("reports the exact shared ancestor and its missing chunks for each conflict", async () => {
const { conflict, core, current } = createCore();
const selectedCurrent = { ...current, _id: "f:selected" };
const selectedConflict = { ...conflict, _id: "f:selected" };
const parent = {
...current,
...selectedCurrent,
_rev: "2-parent",
_conflicts: undefined,
_revs_info: [
@@ -212,7 +214,7 @@ describe("file database information", () => {
core.localDatabase.localDatabase.get.mockImplementation(async (_id: string, options?: { rev?: string }) => {
if (options?.rev === "2-conflict") {
return {
...conflict,
...selectedConflict,
_revs_info: [
{ rev: "2-conflict", status: "available" },
{ rev: "2-parent", status: "available" },
@@ -224,7 +226,7 @@ describe("file database information", () => {
return parent;
}
return {
...current,
...selectedCurrent,
_revs_info: [
{ rev: "3-current", status: "available" },
{ rev: "2-parent", status: "available" },
@@ -233,7 +235,10 @@ describe("file database information", () => {
};
});
const info = await inspectFileDatabaseInfo(core as never, "note.md");
const info = await inspectFileDatabaseInfo(core as never, "note.md", {
documentId: "f:selected",
databasePath: "note.md" as never,
});
expect(info.database.mergeBases).toEqual([
{
@@ -246,6 +251,64 @@ describe("file database information", () => {
unavailableSharedRevisions: ["1-root"],
},
]);
expect(core.localDatabase.localDatabase.get.mock.calls.map(([id]) => id)).toEqual([
"f:selected",
"f:selected",
"f:selected",
]);
});
it("inspects the exact selected document when its ID differs from the path-derived ID", async () => {
const { core, current } = createCore();
const selected = {
...current,
_id: "f:selected",
_rev: "4-selected",
_conflicts: [],
path: "stored-name.md",
children: [],
};
core.localDatabase.localDatabase.get.mockImplementation(async (id: string) =>
id === "f:selected" ? selected : current
);
const info = await inspectFileDatabaseInfo(core as never, "note.md", {
documentId: "f:selected",
databasePath: "listed-name.md" as never,
});
expect(info.database.exists).toBe(true);
expect(info.database.revisions).toEqual([
expect.objectContaining({
documentId: "f:selected",
revision: "4-selected",
}),
]);
expect(info.databasePath).toBe("stored-name.md");
expect(info.database.documentId).toBe("f:selected");
expect(core.services.path.path2id).not.toHaveBeenCalled();
});
it("reports a missing exact document without falling back to the path-derived ID", async () => {
const { core, current } = createCore();
core.localDatabase.localDatabase.get.mockImplementation(async (id: string) => {
if (id === "f:missing") {
throw Object.assign(new Error("missing"), { status: 404 });
}
return current;
});
const info = await inspectFileDatabaseInfo(core as never, "note.md", {
documentId: "f:missing",
databasePath: "requested-name.md" as never,
});
expect(info.database.exists).toBe(false);
expect(info.database.revisions).toEqual([]);
expect(info.databasePath).toBe("requested-name.md");
expect(info.database.documentId).toBe("f:missing");
expect(core.localDatabase.localDatabase.get.mock.calls.map(([id]) => id)).toEqual(["f:missing"]);
expect(core.services.path.path2id).not.toHaveBeenCalled();
});
it("does not decode a revision whose chunks are not all available locally", async () => {
@@ -293,13 +356,7 @@ describe("file database information", () => {
await retryReadFileDatabaseRevision(core as never, "note.md", "2-conflict");
expect(core.localDatabase.getDBEntry).toHaveBeenCalledWith(
"note.md",
{ rev: "2-conflict" },
false,
true,
true
);
expect(core.localDatabase.getDBEntry).toHaveBeenCalledWith("note.md", { rev: "2-conflict" }, false, true, true);
});
it("reports the exact revision as locally available after retry recovers its missing chunk", async () => {
@@ -322,14 +379,10 @@ describe("file database information", () => {
: [],
}));
await expect(
retryReadFileDatabaseRevision(core as never, "note.md", "2-conflict")
).resolves.not.toBe(false);
await expect(retryReadFileDatabaseRevision(core as never, "note.md", "2-conflict")).resolves.not.toBe(false);
const information = await inspectFileDatabaseInfo(core as never, "note.md");
expect(
information.database.revisions.find(({ revision }) => revision === "2-conflict")
).toEqual(
expect(information.database.revisions.find(({ revision }) => revision === "2-conflict")).toEqual(
expect.objectContaining({
contentAvailableLocally: true,
chunks: [
@@ -23,11 +23,12 @@ export const useDatabaseDiagnosticsFeature = createServiceFeature((host: Databas
try {
await services.UI.dialogManager.open<"close", DatabaseDiagnosticDialogOptions>(DatabaseDiagnosticDialog, {
kind,
run: async (nameMode, onProgress) => {
run: async (nameMode, onProgress, includeRemote = true) => {
if (!services.database.isDatabaseReady()) throw new Error("local-database-unavailable");
return await runDatabaseDiagnostic(host, kind, nameMode, {
signal: controller.signal,
onProgress,
includeRemote,
});
},
cancel: () => controller.abort(),
@@ -119,4 +119,29 @@ describe("database diagnostics serviceFeature", () => {
data.close();
await opened;
});
it("forwards the per-run remote inspection choice and defaults existing callers to include the remote database", async () => {
vi.mocked(runDatabaseDiagnostic).mockClear();
const data = fixture();
const opened = data.view.open("integrity");
const options = data.openDialog.mock.calls[0][1];
await options.run("pseudonym", () => {}, false);
expect(runDatabaseDiagnostic).toHaveBeenLastCalledWith(
data.host,
"integrity",
"pseudonym",
expect.objectContaining({ includeRemote: false, signal: expect.any(AbortSignal) })
);
await options.run("pseudonym", () => {});
expect(runDatabaseDiagnostic).toHaveBeenLastCalledWith(
data.host,
"integrity",
"pseudonym",
expect.objectContaining({ includeRemote: true, signal: expect.any(AbortSignal) })
);
data.close();
await opened;
});
});