Merge pull request #1309 from vrtmrz/feature/nfc-journal-p2p

Expose NFC path identity for Object Storage and P2P
This commit is contained in:
vorotamoroz authored and GitHub committed 2026-11-05 18:39:57 +09:00
commit 5a21255fb7
47 files changed
+3711 -461

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
import type { RemoteConnectionProbe } from "@vrtmrz/livesync-commonlib/replication";
import { withOwnedRemoteResource } from "./ownedRemoteResource";
/** Select initial settings from a read-only declaration before opening the local database. */
export async function resolveInitialPathIdentity(
probe: RemoteConnectionProbe | undefined,
newEnvironmentVersion: number
): Promise<0 | 1> {
if (!probe) throw new Error("The remote path identity cannot be inspected.");
return await withOwnedRemoteResource(probe, async (ownedProbe) => {
if (!ownedProbe.inspectPathIdentity) throw new Error("The remote path identity cannot be inspected.");
const result = await ownedProbe.inspectPathIdentity();
return result.status === "empty" ? (newEnvironmentVersion === 1 ? 1 : 0) : result.pathNormalizationVersion;
});
}
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import type { PathIdentityInspection, RemoteConnectionProbe } from "@vrtmrz/livesync-commonlib/replication";
import { resolveInitialPathIdentity } from "./initialPathIdentity";
function makeProbe(result: PathIdentityInspection) {
return {
check: vi.fn(),
getStatus: vi.fn(),
inspectPathIdentity: vi.fn(async () => result),
dispose: vi.fn(async () => undefined),
} satisfies RemoteConnectionProbe;
}
describe("resolveInitialPathIdentity", () => {
it.each([0, 1] as const)("adopts existing version %s instead of the new-environment default", async (version) => {
const probe = makeProbe({ status: "configured", pathNormalizationVersion: version });
await expect(resolveInitialPathIdentity(probe, 1 - version)).resolves.toBe(version);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it.each([0, 1] as const)("retains version %s only for a confirmed empty remote", async (version) => {
const probe = makeProbe({ status: "empty" });
await expect(resolveInitialPathIdentity(probe, version)).resolves.toBe(version);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it("preserves inspection failures and disposes the probe", async () => {
const probe = makeProbe({ status: "empty" });
const error = new Error("remote unavailable");
probe.inspectPathIdentity.mockRejectedValue(error);
await expect(resolveInitialPathIdentity(probe, 1)).rejects.toBe(error);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it("refuses an unavailable inspection capability without guessing", async () => {
const probe = makeProbe({ status: "empty" });
const { inspectPathIdentity: _inspect, ...unsupported } = probe;
await expect(resolveInitialPathIdentity(unsupported, 1)).rejects.toThrow("cannot be inspected");
expect(probe.dispose).toHaveBeenCalledOnce();
await expect(resolveInitialPathIdentity(undefined, 1)).rejects.toThrow("cannot be inspected");
});
});
@@ -7,6 +7,9 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"File identity": "File identity",
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first.":
"Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild from a complete authoritative Vault and Fetch on the other devices. With P2P, Rebuild prepares only the source device's local database. Resolve colliding file names first.",
"Synchronisation protection": "Synchronisation protection",
"Synchronising your Vault": "Synchronising your Vault",
"Receiving changes from the server…": "Receiving changes from the server…",
+10
View File
@@ -0,0 +1,10 @@
import { createNewVaultSettings as createCommonlibNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
/** Create settings for a new LiveSync environment. */
export function createNewVaultSettings(): ObsidianLiveSyncSettings {
return {
...createCommonlibNewVaultSettings(),
pathNormalizationVersion: 1,
};
}
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
NEW_VAULT_SETTINGS,
SETTINGS_SCHEMA_DEFAULTS,
createNewVaultSettings as createCommonlibNewVaultSettings,
} from "@vrtmrz/livesync-commonlib/settings";
import { createNewVaultSettings } from "./newVaultSettings.ts";
describe("createNewVaultSettings", () => {
it("selects NFC path identity only for new host settings", () => {
expect(SETTINGS_SCHEMA_DEFAULTS.pathNormalizationVersion).toBe(0);
expect(NEW_VAULT_SETTINGS.pathNormalizationVersion).toBe(0);
expect(createCommonlibNewVaultSettings().pathNormalizationVersion).toBe(0);
expect(createNewVaultSettings().pathNormalizationVersion).toBe(1);
});
it("keeps remote configurations independent of shared and other new-Vault settings", () => {
const first = createNewVaultSettings();
const second = createNewVaultSettings();
const shared = createCommonlibNewVaultSettings();
expect(first.remoteConfigurations).not.toBe(second.remoteConfigurations);
expect(first.remoteConfigurations).not.toBe(shared.remoteConfigurations);
expect(first.pluginSyncExtendedSetting).not.toBe(second.pluginSyncExtendedSetting);
expect(first.pluginSyncExtendedSetting).not.toBe(shared.pluginSyncExtendedSetting);
first.remoteConfigurations.example = {
id: "example",
name: "Example",
uri: "sls+http://localhost/",
isEncrypted: false,
};
expect(second.remoteConfigurations).toEqual({});
expect(shared.remoteConfigurations).toEqual({});
expect(NEW_VAULT_SETTINGS.remoteConfigurations).toEqual({});
expect(NEW_VAULT_SETTINGS.pluginSyncExtendedSetting).toEqual({});
});
});
+42 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { LOG_LEVEL_NOTICE, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_NOTICE, REMOTE_COUCHDB, REMOTE_MINIO, VERSIONING_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
objectStorage: [] as Array<{
host: unknown;
tryConnectRemote: ReturnType<typeof vi.fn>;
inspectRemotePathIdentity: ReturnType<typeof vi.fn>;
getRemoteStatus: ReturnType<typeof vi.fn>;
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
@@ -64,6 +65,7 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
LiveSyncJournalReplicator: class {
host: unknown;
tryConnectRemote = vi.fn();
inspectRemotePathIdentity = vi.fn();
getRemoteStatus = vi.fn();
getRemotePreferredTweakValues = vi.fn();
getReplicationPBKDF2Salt = vi.fn();
@@ -135,6 +137,45 @@ describe("replicator probe factories", () => {
expect(databaseClose).not.toHaveBeenCalled();
});
it("inspects CouchDB identity without encryption setup or remote writes and closes on failure", async () => {
const source = { ...createSettings(), encrypt: true };
const probe = await createCouchDBConnectionProbeFactory({} as never)(source);
const replicator = mocks.couchDB[0];
const get = vi.fn(async () => ({ _id: VERSIONING_DOCID, type: "versioninfo", version: 13, used_features: ["nfc-path-identity-v1"] }));
const close = vi.fn(async () => undefined);
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({ db: { get }, close });
await expect(probe.inspectPathIdentity!()).resolves.toEqual({
status: "configured",
pathNormalizationVersion: 1,
});
expect(replicator.connectRemoteCouchDBWithSetting).toHaveBeenCalledWith(
{ ...source, encrypt: false },
false,
false,
false
);
expect(close).toHaveBeenCalledOnce();
get.mockRejectedValueOnce(new Error("read unavailable"));
await expect(probe.inspectPathIdentity!()).rejects.toThrow("read unavailable");
expect(close).toHaveBeenCalledTimes(2);
expect(replicator.getReplicationPBKDF2Salt).not.toHaveBeenCalled();
});
it("delegates Journal identity inspection to the owned settings snapshot", async () => {
const source = createSettings();
const snapshot = { ...source };
const probe = await createObjectStorageConnectionProbeFactory({} as never)(source);
const replicator = mocks.objectStorage[0];
replicator.inspectRemotePathIdentity.mockResolvedValue({ status: "empty" });
source.bucket = "changed";
await expect(probe.inspectPathIdentity!()).resolves.toEqual({ status: "empty" });
expect(replicator.inspectRemotePathIdentity).toHaveBeenCalledWith(snapshot);
expect(replicator.tryConnectRemote).not.toHaveBeenCalled();
expect(replicator.getReplicationPBKDF2Salt).not.toHaveBeenCalled();
await probe.dispose();
expect(replicator.closeReplication).toHaveBeenCalledOnce();
});
it("maps a CouchDB connection error string and delegates status to the same snapshot", async () => {
const source = createSettings();
const snapshot = { ...source };
@@ -8,6 +8,7 @@ import {
LiveSyncCouchDBReplicator,
type LiveSyncCouchDBReplicatorEnv,
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { inspectDatabasePathIdentity } from "@vrtmrz/livesync-commonlib/replication";
import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared";
@@ -51,6 +52,20 @@ function createCouchDBConnectionProbe(
await connection.close();
}
},
inspectPathIdentity: async () => {
const connection = await replicator.connectRemoteCouchDBWithSetting(
{ ...snapshot, encrypt: false },
replicator.isMobile(),
false,
false
);
if (typeof connection === "string") throw new Error(connection);
try {
return await inspectDatabasePathIdentity(connection.db);
} finally {
await connection.close();
}
},
getStatus: () => replicator.getRemoteStatus(snapshot),
dispose,
};
@@ -70,6 +85,7 @@ function createObjectStorageConnectionProbe(
return { ok: false, reason: error };
}
},
inspectPathIdentity: () => replicator.inspectRemotePathIdentity(snapshot),
getStatus: () => replicator.getRemoteStatus(snapshot),
dispose,
};