mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-29 23:07:08 +00:00
Add safe Metadata document ID repair
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user