Add opt-in Unicode NFC path identity for CouchDB

This commit is contained in:
vorotamoroz committed 2026-10-17 13:31:19 +00:00
1 parent 72dc88c7ef
commit ddb4d52931
39 files changed
+2245 -97

No files matched your search

@@ -1,4 +1,9 @@
import { assessRemoteFeatureDocument, describeRemoteFeatureRejection } from "@vrtmrz/livesync-commonlib/replication";
import {
assessRemoteFeatureDocument,
describeRemoteFeatureRejection,
NFC_PATH_IDENTITY_FEATURE,
} from "@vrtmrz/livesync-commonlib/replication";
import { getPathNormalizationVersion } from "@vrtmrz/livesync-commonlib/settings";
import {
SYNCINFO_ID,
VERSIONING_DOCID,
@@ -33,7 +38,7 @@ const REPROCESS_BATCH_SIZE = 100;
type ReplicateResultProcessorSettings = Pick<
ObsidianLiveSyncSettings,
"maxMTimeForReflectEvents" | "suspendParseReplicationResult"
>;
> & { pathNormalizationVersion?: 0 | 1 };
type ReplicateResultProcessorServices = Pick<
LiveSyncBaseCore["services"],
"appLifecycle" | "database" | "path" | "replication" | "vault"
@@ -285,13 +290,20 @@ export class ReplicateResultProcessor {
if (change._id === VERSIONING_DOCID || change.type === "versioninfo") {
this.log(`Version info document received: ${change._id}`, LOG_LEVEL_VERBOSE);
const assessment = assessRemoteFeatureDocument(change);
if (assessment.status !== "supported" && assessment.status !== "older-generation") {
const pathIdentityDoesNotMatch =
assessment.status === "supported" &&
assessment.usedFeatures.includes(NFC_PATH_IDENTITY_FEATURE) !==
(getPathNormalizationVersion(this.context.currentSettings()) === 1);
if (
(assessment.status !== "supported" && assessment.status !== "older-generation") ||
pathIdentityDoesNotMatch
) {
// Fence and retire the active publication through its owner.
this.context.requestActiveReplicatorRetirement();
this.log(
`${describeRemoteFeatureRejection(assessment)} Update Self-hosted LiveSync before synchronising.`,
LOG_LEVEL_NOTICE
);
const rejection = pathIdentityDoesNotMatch
? "Local and remote path identity settings do not match. Select the matching mode and Fetch the migrated database before synchronising."
: `${describeRemoteFeatureRejection(assessment)} Update Self-hosted LiveSync before synchronising.`;
this.log(rejection, LOG_LEVEL_NOTICE);
}
return true;
}
@@ -6,6 +6,7 @@ import {
type EntryDoc,
type FilePathWithPrefix,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import * as replicationCompatibility from "@vrtmrz/livesync-commonlib/replication";
import { ENCRYPTED_INTERNAL_METADATA_FEATURE, REMOTE_FEATURE_GENERATION } from "@vrtmrz/livesync-commonlib/replication";
import {
isValidFilenameInAndroid,
@@ -46,6 +47,7 @@ type SetupOptions = {
localVersionInfo?: unknown;
isTargetFile?: (path: string) => Promise<boolean>;
databaseId?: string;
pathNormalizationVersion?: number;
};
function setup(options: SetupOptions = {}) {
@@ -96,6 +98,7 @@ function setup(options: SetupOptions = {}) {
currentSettings: () => ({
maxMTimeForReflectEvents: options.maxMTimeForReflectEvents ?? 0,
suspendParseReplicationResult: false,
pathNormalizationVersion: options.pathNormalizationVersion ?? 0,
}),
getKeyValueDB: () => core.kvDB,
getLocalDatabase: () => core.localDatabase,
@@ -322,6 +325,66 @@ describe("ReplicateResultProcessor", () => {
expect(onCloseActiveReplication).not.toHaveBeenCalled();
});
it.each([
["local version 0 and remote version 1", 0, ["nfc-path-identity-v1"], true],
["local version 1 and remote version 0", 1, [], true],
["both use version 1", 1, ["nfc-path-identity-v1"], false],
["both use version 0", 0, [], false],
] as const)(
"checks path identity compatibility for %s",
(_description, localVersion, remoteFeatures, shouldRetire) => {
const originalAssessment = replicationCompatibility.assessRemoteFeatureDocument;
const assessmentSpy = vi
.spyOn(replicationCompatibility, "assessRemoteFeatureDocument")
.mockImplementation((document) => {
const assessment = originalAssessment(document);
if (
assessment.status !== "unknown-features" ||
assessment.identifiers.length !== 1 ||
assessment.identifiers[0] !== "nfc-path-identity-v1"
) {
return assessment;
}
return { status: "supported", usedFeatures: ["nfc-path-identity-v1"] };
});
try {
const { onCloseActiveReplication, processor } = setup({ pathNormalizationVersion: localVersion });
processor.enqueueAll([
{
_id: VERSIONING_DOCID,
_rev: "2-path-identity",
type: "versioninfo",
version: REMOTE_FEATURE_GENERATION,
used_features: [...remoteFeatures],
} as PouchDB.Core.ExistingDocument<EntryDoc>,
]);
if (shouldRetire) {
expect(onCloseActiveReplication).toHaveBeenCalledOnce();
} else {
expect(onCloseActiveReplication).not.toHaveBeenCalled();
}
} finally {
assessmentSpy.mockRestore();
}
}
);
it("keeps the existing older-generation gate for local path identity version 1", () => {
const { onCloseActiveReplication, processor } = setup({ pathNormalizationVersion: 1 });
processor.enqueueAll([
{
_id: VERSIONING_DOCID,
_rev: "1-legacy-path-identity",
type: "versioninfo",
version: 11,
} as PouchDB.Core.ExistingDocument<EntryDoc>,
]);
expect(onCloseActiveReplication).not.toHaveBeenCalled();
});
it("reports unknown feature identifiers and requests Replicator retirement", () => {
const logger = vi.fn();
setGlobalLogFunction(logger);