Add safe Metadata document ID repair

This commit is contained in:
vorotamoroz
2026-08-12 18:56:17 +00:00
parent eda78dee36
commit c933674a0d
13 changed files with 715 additions and 9 deletions
@@ -0,0 +1,31 @@
import type { MetadataDocumentIdentityIssue } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
export function metadataIdentityPathKey(path: string, handleFilenameCaseSensitive: boolean): string {
const vaultPath = stripAllPrefixes(path as FilePathWithPrefix);
return handleFilenameCaseSensitive ? vaultPath : vaultPath.toLowerCase();
}
/**
* Select unresolved identity evidence for read-only presentation and create
* the path keys which must be withheld from ordinary path-based repair.
*/
export function selectUnresolvedMetadataIdentityEntries(
entries: readonly MetadataDocumentIdentityIssue[],
handleFilenameCaseSensitive: boolean
): {
entries: MetadataDocumentIdentityIssue[];
unresolvedPathKeys: ReadonlySet<string>;
} {
return {
entries: [...entries],
unresolvedPathKeys: new Set(
entries
.filter(({ ordinaryPathAvailable }) => !ordinaryPathAvailable)
.map(({ inspection }) =>
metadataIdentityPathKey(inspection.diagnostic.declaredPath, handleFilenameCaseSensitive)
)
),
};
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import type { MetadataDocumentIdentityIssue } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { metadataIdentityPathKey, selectUnresolvedMetadataIdentityEntries } from "./metadataIdentityInspection";
function createEntries(): MetadataDocumentIdentityIssue[] {
return [
{
inspection: {
status: "unresolved",
diagnostic: {
reason: "document-id-mismatch",
actualDocumentId: "f:stale",
declaredPath: "Folder/Renamed.md",
expectedDocumentId: "f:renamed",
actualNamespace: "normal",
declaredPathNamespace: "normal",
},
},
sourceRevision: "3-stale",
logicallyDeleted: false,
conflictRevisions: [],
repairAvailable: false,
targetAlreadyPresent: false,
ordinaryPathAvailable: false,
},
{
inspection: {
status: "unresolved",
diagnostic: {
reason: "namespace-mismatch",
actualDocumentId: "f:stale-internal-path",
declaredPath: "i:.Obsidian/App.json",
actualNamespace: "normal",
declaredPathNamespace: "internal",
},
},
sourceRevision: "2-stale",
logicallyDeleted: false,
conflictRevisions: [],
repairAvailable: false,
targetAlreadyPresent: false,
ordinaryPathAvailable: false,
},
] as unknown as MetadataDocumentIdentityIssue[];
}
describe("Metadata identity inspection presentation", () => {
it("derives case-insensitive Vault path keys for unresolved evidence", () => {
const result = selectUnresolvedMetadataIdentityEntries(createEntries(), false);
expect(result.entries.map(({ sourceRevision }) => sourceRevision)).toEqual(["3-stale", "2-stale"]);
expect([...result.unresolvedPathKeys]).toEqual(["folder/renamed.md", ".obsidian/app.json"]);
expect(metadataIdentityPathKey("folder/RENAMED.md", false)).toBe("folder/renamed.md");
expect(result.unresolvedPathKeys.has(metadataIdentityPathKey("folder/RENAMED.md", false))).toBe(true);
});
it("retains case distinctions when filename handling is case-sensitive", () => {
const result = selectUnresolvedMetadataIdentityEntries(createEntries(), true);
expect(result.unresolvedPathKeys.has("Folder/Renamed.md")).toBe(true);
expect(result.unresolvedPathKeys.has("folder/renamed.md")).toBe(false);
});
it("does not suppress ordinary inspection when the path has resolvable Metadata", () => {
const entries = createEntries();
entries[0] = {
...entries[0],
ordinaryPathAvailable: true,
} as MetadataDocumentIdentityIssue;
const result = selectUnresolvedMetadataIdentityEntries(entries, false);
expect(result.entries).toHaveLength(2);
expect(result.unresolvedPathKeys.has("folder/renamed.md")).toBe(false);
expect(result.unresolvedPathKeys.has(".obsidian/app.json")).toBe(true);
});
});
@@ -0,0 +1,71 @@
import type {
MetadataDocumentRepairRequest,
MetadataDocumentRepairResult,
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { MetadataDocumentRepairResults } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
export const MetadataIdentityRepairExecutions = {
CANCELLED: "cancelled",
REPAIR_RESULT: "repair-result",
} as const;
export type MetadataIdentityRepairExecution =
| { status: typeof MetadataIdentityRepairExecutions.CANCELLED }
| {
status: typeof MetadataIdentityRepairExecutions.REPAIR_RESULT;
result: MetadataDocumentRepairResult;
scanCompleted: boolean;
scanError?: unknown;
};
export interface MetadataIdentityRepairDependencies {
confirm: () => Promise<boolean>;
repair: (request: MetadataDocumentRepairRequest) => Promise<MetadataDocumentRepairResult>;
requestOrdinaryScan: () => Promise<boolean>;
}
/**
* Coordinate one explicitly confirmed Metadata identity repair.
*
* This consumer boundary deliberately keeps inspection approval, Commonlib
* mutation, and the subsequent ordinary Vault scan as separate operations.
* Cancellation cannot reach the mutation, and only a completed repair hands
* reconciliation back to the Offline Scanner. Commonlib re-inspects the
* source and expected ID under the current local path settings immediately
* before mutation, so remote replication state is not part of this boundary.
*/
export async function executeMetadataIdentityRepair(
request: MetadataDocumentRepairRequest,
dependencies: MetadataIdentityRepairDependencies
): Promise<MetadataIdentityRepairExecution> {
if (!(await dependencies.confirm())) {
return { status: MetadataIdentityRepairExecutions.CANCELLED };
}
const result = await dependencies.repair(request);
if (result.status !== MetadataDocumentRepairResults.COMPLETED) {
return {
status: MetadataIdentityRepairExecutions.REPAIR_RESULT,
result,
scanCompleted: false,
};
}
try {
const scanCompleted = await dependencies.requestOrdinaryScan();
return {
status: MetadataIdentityRepairExecutions.REPAIR_RESULT,
result,
scanCompleted,
};
} catch (scanError) {
// The Metadata identity mutation has already completed. Preserve that
// result separately so a follow-up scan failure cannot be mistaken for
// a failed or rolled-back repair.
return {
status: MetadataIdentityRepairExecutions.REPAIR_RESULT,
result,
scanCompleted: false,
scanError,
};
}
}
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from "vitest";
import type { DocumentID } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type {
MetadataDocumentRepairRequest,
MetadataDocumentRepairResult,
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { executeMetadataIdentityRepair } from "./metadataIdentityRepair";
const request: MetadataDocumentRepairRequest = {
actualDocumentId: "f:stale" as DocumentID,
expectedDocumentId: "f:expected" as DocumentID,
sourceRevision: "4-source",
};
function createDependencies() {
const events: string[] = [];
return {
events,
confirm: vi.fn(async () => true),
repair: vi.fn(async (): Promise<MetadataDocumentRepairResult> => {
events.push("repair");
return {
status: "completed" as const,
...request,
targetCreated: true,
};
}),
requestOrdinaryScan: vi.fn(async () => {
events.push("scan");
return true;
}),
};
}
describe("executeMetadataIdentityRepair", () => {
it("performs no mutation when the separate confirmation is cancelled", async () => {
const dependencies = createDependencies();
dependencies.confirm.mockResolvedValue(false);
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toEqual({
status: "cancelled",
});
expect(dependencies.repair).not.toHaveBeenCalled();
expect(dependencies.requestOrdinaryScan).not.toHaveBeenCalled();
});
it("requests an ordinary scan only after Commonlib completes the exact repair", async () => {
const dependencies = createDependencies();
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toMatchObject({
status: "repair-result",
result: { status: "completed" },
scanCompleted: true,
});
expect(dependencies.repair).toHaveBeenCalledWith(request);
expect(dependencies.requestOrdinaryScan).toHaveBeenCalledOnce();
expect(dependencies.repair.mock.invocationCallOrder[0]).toBeLessThan(
dependencies.requestOrdinaryScan.mock.invocationCallOrder[0]
);
expect(dependencies.events).toEqual(["repair", "scan"]);
});
it("keeps a completed repair distinct when the ordinary scan cannot start", async () => {
const dependencies = createDependencies();
dependencies.requestOrdinaryScan.mockResolvedValue(false);
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toMatchObject({
status: "repair-result",
result: { status: "completed" },
scanCompleted: false,
});
expect(dependencies.requestOrdinaryScan).toHaveBeenCalledOnce();
expect(dependencies.events).toEqual(["repair"]);
});
it("keeps a completed repair distinct when requesting the ordinary scan throws", async () => {
const dependencies = createDependencies();
const error = new Error("scan unavailable");
dependencies.requestOrdinaryScan.mockRejectedValue(error);
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toMatchObject({
status: "repair-result",
result: { status: "completed" },
scanCompleted: false,
scanError: error,
});
expect(dependencies.events).toEqual(["repair"]);
});
it("does not scan after a stale, blocked, or failed repair result", async () => {
for (const status of ["stale", "blocked", "failed"] as const) {
const dependencies = createDependencies();
dependencies.repair.mockResolvedValue({
status,
...request,
targetCreated: false,
});
await expect(executeMetadataIdentityRepair(request, dependencies)).resolves.toMatchObject({
status: "repair-result",
result: { status },
scanCompleted: false,
});
expect(dependencies.requestOrdinaryScan).not.toHaveBeenCalled();
}
});
});