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

+7 -3
View File
@@ -418,7 +418,8 @@ The CLI uses the same settings format as the Obsidian plug-in. Create a `.livesy
#### Unicode path identity
The `pathNormalizationVersion` setting controls how file paths identify
Metadata documents when using CouchDB. An absent value or `0` keeps the legacy
Metadata documents. CouchDB support is released; the unreleased extension also
supports Object Storage and P2P. An absent value or `0` keeps the legacy
identity. Version `1` uses Unicode Normalization Form C (NFC) for Metadata IDs
and path comparison keys. It does not rewrite `Metadata.path`, rename existing
files, or change Chunks or file content. `handleFilenameCaseSensitive` remains
@@ -442,8 +443,11 @@ with `--force --write-settings`. Set the same value on each remaining CLI
client, then run `fetch` with `--force --write-settings`. Use **Overwrite
Server Data with This Device's Files** and **Reset Synchronisation on This
Device** for the corresponding Obsidian Rebuild and Fetch operations. Resolve
any ambiguous names before reconstruction. Version `1` is supported with
CouchDB only; do not resume a legacy client against the rebuilt database.
any ambiguous names before reconstruction. Object Storage Rebuild replaces the
remote journals as well as the local database. P2P Rebuild prepares only the
source local database; keep that peer online for the other devices to Fetch.
Every P2P participant must be updated and migrated. Do not resume a legacy
client against the rebuilt database.
### Command-line Reference
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { createNewVaultSettings } from "@/common/newVaultSettings.ts";
export function createDefaultCliSettings(): ObsidianLiveSyncSettings {
return {
@@ -7,10 +7,14 @@ describe("createDefaultCliSettings", () => {
const settings = createDefaultCliSettings();
const recommended = createNewVaultSettings();
expect(settings.pathNormalizationVersion).toBe(1);
expect(settings).toEqual({
...recommended,
pathNormalizationVersion: 1,
useIndexedDBAdapter: false,
isConfigured: false,
});
expect(recommended.pathNormalizationVersion).toBe(0);
expect(settings.remoteConfigurations).not.toBe(recommended.remoteConfigurations);
});
});
+20
View File
@@ -0,0 +1,20 @@
import {
REMOTE_COUCHDB,
REMOTE_MINIO,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { RemoteConnectionProbe } from "@vrtmrz/livesync-commonlib/replication";
import { resolveInitialPathIdentity } from "@/common/initialPathIdentity";
/** Adopt an existing central remote only for the first explicit Fetch. */
export async function resolveInitialFetchPathIdentity(
settings: ObsidianLiveSyncSettings,
createProbe: () => Promise<RemoteConnectionProbe | undefined>
): Promise<0 | 1 | undefined> {
if (settings.isConfigured) return undefined;
const hasRemote =
(settings.remoteType === REMOTE_COUCHDB && !!settings.couchDB_URI && !!settings.couchDB_DBNAME) ||
(settings.remoteType === REMOTE_MINIO && !!settings.endpoint && !!settings.bucket);
if (!hasRemote) return undefined;
return await resolveInitialPathIdentity(await createProbe(), settings.pathNormalizationVersion);
}
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { REMOTE_COUCHDB, REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createDefaultCliSettings } from "./cliSettingsDefaults";
import { resolveInitialFetchPathIdentity } from "./initialFetchPathIdentity";
function fixture() {
const settings = {
...createDefaultCliSettings(),
couchDB_URI: "https://db.test",
couchDB_DBNAME: "vault",
endpoint: "https://objects.test",
bucket: "vault",
};
const probe = {
check: vi.fn(),
getStatus: vi.fn(),
inspectPathIdentity: vi.fn(async () => ({
status: "configured" as const,
pathNormalizationVersion: 0 as const,
})),
dispose: vi.fn(async () => undefined),
};
return { settings, probe, createProbe: vi.fn(async () => probe) };
}
describe("initial CLI Fetch path identity", () => {
it.each([REMOTE_COUCHDB, REMOTE_MINIO])("adopts a legacy %s remote before the first Fetch", async (remoteType) => {
const { settings, probe, createProbe } = fixture();
settings.remoteType = remoteType;
await expect(resolveInitialFetchPathIdentity(settings, createProbe)).resolves.toBe(0);
expect(settings.pathNormalizationVersion).toBe(1);
expect(probe.dispose).toHaveBeenCalledOnce();
});
it("leaves an already configured device unchanged", async () => {
const { settings, createProbe } = fixture();
settings.isConfigured = true;
await expect(resolveInitialFetchPathIdentity(settings, createProbe)).resolves.toBeUndefined();
expect(createProbe).not.toHaveBeenCalled();
});
it.each(["unconfigured", "p2p"])("does not infer identity for %s settings", async (kind) => {
const { settings, createProbe } = fixture();
if (kind === "p2p") settings.remoteType = REMOTE_P2P;
else settings.couchDB_URI = "";
await expect(resolveInitialFetchPathIdentity(settings, createProbe)).resolves.toBeUndefined();
expect(createProbe).not.toHaveBeenCalled();
});
});
+40
View File
@@ -5,6 +5,7 @@ import { join } from "node:path";
import * as chokidar from "chokidar";
import { path } from "@vrtmrz/livesync-commonlib/node";
import * as offlineScanner from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import {
AutoAccepting,
@@ -333,6 +334,45 @@ describe("CLI database preparation", () => {
expect(watch).not.toHaveBeenCalled();
});
it("retains the adopted initial Fetch identity without --write-settings", async () => {
await writeFile(
settingsPath,
JSON.stringify({
...createDefaultCliSettings(),
isConfigured: false,
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
couchDB_USER: "user",
couchDB_PASSWORD: "pass",
})
);
const dispose = vi.fn(async () => undefined);
const inspect = vi.spyOn(ReplicatorService.prototype, "createRemoteResource").mockResolvedValue({
check: vi.fn(),
getStatus: vi.fn(),
inspectPathIdentity: vi.fn(async () => ({ status: "configured", pathNormalizationVersion: 0 }) as const),
dispose,
});
await start(
"fetch",
async (_options, { core }) => {
expect(core.services.setting.currentSettings().pathNormalizationVersion).toBe(0);
return true;
},
0,
true
);
const persisted = JSON.parse(await readFile(settingsPath, "utf-8"));
expect(persisted.pathNormalizationVersion).toBe(0);
expect(dispose).toHaveBeenCalledOnce();
expect(inspect).toHaveBeenCalledOnce();
await start("ls", async (_options, { core }) => {
expect(core.services.setting.currentSettings().pathNormalizationVersion).toBe(0);
return false;
});
expect(inspect).toHaveBeenCalledOnce();
});
it.each(["rebuild", "fetch"] as const)("does not scan or mirror legacy Vault files before %s", async (command) => {
const storedPaths: string[] = [];
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
+19
View File
@@ -30,6 +30,8 @@ import type { ReplicationSchedulingControl } from "@/serviceFeatures/replication
import { createNodeStandardIo, fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
import { writeStderrLine, writeStdoutLine } from "./cliOutput";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
import { resolveInitialFetchPathIdentity } from "./initialFetchPathIdentity";
import { createDefaultCliSettings } from "./cliSettingsDefaults";
import {
applyStoredSetting,
@@ -601,6 +603,22 @@ export async function main(
throw new Error("Replication scheduling was not provided during core feature composition.");
}
let initialFetchPathIdentity: 0 | 1 | undefined;
if (options.command === "fetch") {
core.services.appLifecycle.onSettingLoaded.addHandler(async () => {
const settings = core.services.setting.currentSettings();
const version = await resolveInitialFetchPathIdentity(settings, () =>
core.services.replicator.createRemoteResource(REMOTE_RESOURCE_KINDS.CONNECTION, settings)
);
if (version !== undefined) {
initialFetchPathIdentity = version;
await core.services.setting.applyPartial({ pathNormalizationVersion: version });
await core.services.setting.saveSettingData();
}
return true;
});
}
// Setup signal handlers for graceful shutdown
const shutdown = async (signal: string) => {
writeStdoutLine(standardIo);
@@ -802,6 +820,7 @@ export async function main(
: (JSON.parse(originalSettingsText) as ObsidianLiveSyncSettings);
durable.additionalSuffixOfDatabaseName =
core.services.setting.currentSettings().additionalSuffixOfDatabaseName;
if (initialFetchPathIdentity !== undefined) durable.pathNormalizationVersion = initialFetchPathIdentity;
settingsTextToCommit = JSON.stringify(durable, null, 2);
}
settingsTextToCommit ??= originalSettingsText;
+1 -1
View File
@@ -39,7 +39,7 @@
"dependencies": {
"@aws-crypto/sha256-js": "5.2.0",
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
"minimatch": "^11.0.3",
"octagonal-wheels": "0.1.56",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-leveldb": "^9.0.0",
+7
View File
@@ -26,6 +26,12 @@ WebApp requires:
Automated browser coverage uses Chromium. Other browsers, background execution, and remote types other than CouchDB remain experimental. Use feature detection rather than relying on a fixed browser-version list.
## Path identity compatibility
WebApp currently supports **Legacy path identity** (`pathNormalizationVersion: 0`) only. **Unicode NFC path identity** (`1`) is not yet supported. Use legacy identity in WebApp's `.livesync/settings.json` and on every client sharing its CouchDB database or P2P group, including new CLI and Obsidian environments which default to NFC.
Changing the setting alone does not migrate an existing NFC database. Migration back to legacy identity requires a coordinated Rebuild and Fetch from a complete, authoritative Vault. Back up the Vault and database, and stop synchronisation on all clients before migrating. Keep WebApp disconnected until the migration is complete. See [Recovery and flag files](../../../docs/recovery.md) before rebuilding.
## Use
Serve the built files over HTTPS, or from `localhost`, then open `webapp.html`.
@@ -44,6 +50,7 @@ For example, a minimal manually configured CouchDB connection includes:
"couchDB_DBNAME": "vault",
"isConfigured": true,
"liveSync": true,
"pathNormalizationVersion": 0,
"syncOnSave": true
}
```
+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,
};
@@ -1,5 +1,4 @@
import { ChunkAlgorithmNames } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { $msg } from "@/common/translation";
import type { SettingSpecGroup } from "./SettingSpec.ts";
export type AdvancedSettingSpecContext = {
@@ -24,18 +23,6 @@ export function createAdvancedSettingSpecGroups({
},
{ key: "customChunkSize", control: { type: "number", min: 0, allowZero: true } },
{ key: "strictContentCheckOnStartup", control: { type: "toggle" } },
{
key: "pathNormalizationVersion",
holdValue: true,
control: {
type: "numeric-dropdown",
options: () => ({
"0": $msg("Legacy path identity"),
"1": $msg("Unicode NFC path identity"),
}),
},
visible: isCouchDB,
},
],
},
{
@@ -8,7 +8,7 @@ export type GeneralSettingSpecContext = {
showVerboseLog: () => boolean;
};
/** Build the shared Appearance and Logging controls. */
/** Build the shared General Settings controls. */
export function createGeneralSettingSpecGroups({
showEditorStatusDetails,
showVerboseLog,
@@ -61,6 +61,22 @@ export function createGeneralSettingSpecGroups({
},
],
},
{
heading: "File identity",
items: [
{
key: "pathNormalizationVersion",
holdValue: true,
control: {
type: "numeric-dropdown",
options: () => ({
"0": $msg("Legacy path identity"),
"1": $msg("Unicode NFC path identity"),
}),
},
},
],
},
];
}
@@ -319,7 +319,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
).toEqual(["Copy the current settings to a Setup URI", "Show QR code"]);
});
it("keeps Appearance, Logging, and Extra menus inside General Settings", () => {
it("keeps Appearance, Logging, File identity, and Extra menus inside General Settings", () => {
const tab = createSettingsTab();
const definitions = tab.getSettingDefinitions();
const general = definitions.find(
@@ -332,7 +332,19 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
(item): item is SettingDefinitionPage => isPage(item) && item.name === "🎚️ Extra menus"
);
expect(generalPages?.map(({ name }) => name)).toEqual(["🎨 Appearance", "📝 Logging", "🎚️ Extra menus"]);
expect(generalPages?.map(({ name }) => name)).toEqual([
"🎨 Appearance",
"📝 Logging",
"🗂️ File identity",
"🎚️ Extra menus",
]);
const fileIdentity = generalPages?.find(({ name }) => name === "🗂️ File identity");
expect(
fileIdentity?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
).toEqual(["pathNormalizationVersion"]);
expect(
fileIdentity?.items?.filter((item) => "action" in item && typeof item.action === "function")
).toHaveLength(1);
expect(
appearance?.items?.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
).toEqual([
@@ -390,7 +402,7 @@ describe("ObsidianLiveSyncSettingTab native page lifecycle", () => {
const tab = createSettingsTab();
const pages = collectPages(tab.getSettingDefinitions());
expect(pages).toHaveLength(14);
expect(pages).toHaveLength(15);
expect(pages.map(({ name }) => name)).toEqual(
expect.arrayContaining(
createSettingsPageCatalogue()
@@ -892,9 +892,9 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
showEditorStatusDetails: () => this.isConfiguredAs("showStatusOnEditor", true),
showVerboseLog: () => this.isConfiguredAs("lessInformationInLog", false),
});
const [appearance, logging] = groups;
if (!appearance || !logging) {
throw new Error("General settings must define Appearance and Logging groups");
const [appearance, logging, fileIdentity] = groups;
if (!appearance || !logging || !fileIdentity) {
throw new Error("General settings must define Appearance, Logging, and File identity groups");
}
return this.createRootGroup("general-settings", [
{
@@ -907,6 +907,11 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
name: `📝 ${logging.heading}`,
items: logging.items,
},
{
type: "page",
name: `🗂️ ${fileIdentity.heading}`,
items: [this.createRebuildRequiredAction(), ...(fileIdentity.items ?? [])],
},
this.createExtraMenusPage(),
]);
}
@@ -1128,7 +1133,7 @@ export class ObsidianLiveSyncSettingTab extends PluginSettingTab {
if (method === "remoteOnly" && this.isDirty("pathNormalizationVersion")) {
Logger(
$msg(
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1."
"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."
),
LOG_LEVEL_NOTICE
);
@@ -581,11 +581,11 @@ describe("ObsidianLiveSyncSettingTab pending-setting initialisation", () => {
expect(Reflect.get(tab.editingSettings, "pathNormalizationVersion")).toBe(1);
expect(tab.isDirty("pathNormalizationVersion")).toBe(true);
expect(vi.mocked($msg)).toHaveBeenCalledWith(
"CouchDB only. Version 0 preserves legacy path identity; version 1 uses Unicode NFC. Physical file names stay unchanged. Update every device and stop synchronisation, then Rebuild both databases from a complete authoritative Vault and Fetch on the other devices. Resolve colliding file names first. P2P and Object Storage do not support version 1."
"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."
);
expect(loggerMocks.Logger).toHaveBeenCalledWith(
expect.stringContaining(
"Rebuild both databases from a complete authoritative Vault and Fetch on the other devices."
"Rebuild from a complete authoritative Vault and Fetch on the other devices."
),
LOG_LEVEL_NOTICE
);
@@ -692,6 +692,30 @@ describe("ObsidianLiveSyncSettingTab declarative settings boundary", () => {
return { tab, saveSettingData };
}
it("includes path identity once in the declarative General settings group", () => {
const { tab } = createSettingsTab();
const group = (
tab as unknown as {
createGeneralSettingsGroup: () => {
items: Array<{
name: string;
items?: Array<{ control?: { key: string }; action?: () => void; visible?: () => boolean }>;
}>;
};
}
).createGeneralSettingsGroup();
const fileIdentity = group.items.find(({ name }) => name === "🗂️ File identity");
expect(fileIdentity).toBeDefined();
const apply = fileIdentity?.items?.find(({ action }) => typeof action === "function");
expect(apply).toBeDefined();
Reflect.set(tab.editingSettings, "pathNormalizationVersion", 1);
expect(apply?.visible?.()).toBe(true);
expect(fileIdentity?.items?.flatMap(({ control }) => (control ? [control.key] : []))).toEqual([
"pathNormalizationVersion",
]);
});
it("awaits the stored passphrase before initialising the editing buffer", async () => {
const { tab } = createSettingsTab();
tab.resetEditingSettings();
@@ -43,7 +43,7 @@ describe("paneAdvanced", () => {
};
paneAdvanced.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(11));
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(10));
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
"Memory cache",
@@ -56,7 +56,6 @@ describe("paneAdvanced", () => {
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Local Database Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
panelElements.get("Transfer Tweak"),
@@ -69,7 +68,6 @@ describe("paneAdvanced", () => {
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { paneGeneral } from "./PaneGeneral.ts";
const settingHarness = vi.hoisted(() => ({
rendered: [] as unknown[],
}));
vi.mock("@/common/translation", () => ({
$msg: (key: string) => key,
$t: (key: string) => key,
}));
vi.mock("./LiveSyncSetting.ts", () => ({
LiveSyncSetting: class LiveSyncSetting {},
}));
vi.mock("./SettingSpec.ts", async (importOriginal) => {
const original = await importOriginal<typeof import("./SettingSpec.ts")>();
return {
...original,
renderLegacySettingSpec: vi.fn((_renderer: unknown, spec: unknown) => {
settingHarness.rendered.push(spec);
}),
};
});
afterEach(() => {
settingHarness.rendered.length = 0;
vi.clearAllMocks();
});
describe("paneGeneral", () => {
it("renders path identity once in the File identity panel and preserves its held value", async () => {
const addPanel = vi.fn((_parent: HTMLElement, heading: string) =>
Promise.resolve({ heading } as unknown as HTMLElement)
);
const host = {
isConfiguredAs: vi.fn(() => false),
};
paneGeneral.call(host as never, {} as HTMLElement, { addPanel } as never);
await vi.waitFor(() => expect(settingHarness.rendered).toHaveLength(14));
expect(addPanel.mock.calls.map(([, heading]) => heading)).toEqual([
"obsidianLiveSyncSettingTab.titleAppearance",
"obsidianLiveSyncSettingTab.titleLogging",
"File identity",
"obsidianLiveSyncSettingTab.titleExtraMenus",
]);
const pathIdentity = settingHarness.rendered.filter(
(spec) => (spec as { key: string }).key === "pathNormalizationVersion"
);
expect(pathIdentity).toHaveLength(1);
expect(pathIdentity[0]).toMatchObject({ holdValue: true });
expect((pathIdentity[0] as { visible?: () => boolean }).visible).toBeUndefined();
});
});
@@ -17,7 +17,7 @@ const rangeMessages = {
};
describe("Advanced setting specifications", () => {
it("lists only the eleven controls currently exposed by the Advanced page", () => {
it("lists only the ten controls currently exposed by the Advanced page", () => {
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => true });
expect(groups.map(({ heading }) => heading)).toEqual([
@@ -31,7 +31,6 @@ describe("Advanced setting specifications", () => {
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -52,13 +51,12 @@ describe("Advanced setting specifications", () => {
expect(spec.disabled).toBeUndefined();
});
it("keeps path identity and the four remote chunk controls CouchDB-only", () => {
it("keeps the four remote chunk controls CouchDB-only", () => {
let couchDB = false;
const groups = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB });
const conditional = groups.flatMap(({ items }) => items).filter(({ visible }) => visible !== undefined);
expect(conditional.map(({ key }) => key)).toEqual([
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -70,18 +68,67 @@ describe("Advanced setting specifications", () => {
expect(conditional.every(({ visible }) => visible?.() === true)).toBe(true);
});
it("exposes CouchDB path identity as a two-choice numeric dropdown", () => {
let couchDB = false;
const spec = createAdvancedSettingSpecGroups({ isCouchDB: () => couchDB })
it("excludes transient settings-dialogue keys from the persisted key type", () => {
type OnDialogOverlap = Extract<keyof OnDialogSettings, PersistedSettingKey>;
expectTypeOf<OnDialogOverlap>().toEqualTypeOf<never>();
});
it("resolves translated Commonlib metadata for every explicitly exposed key", () => {
const specs = [
...createAdvancedSettingSpecGroups({ isCouchDB: () => true }),
...createGeneralSettingSpecGroups({
showEditorStatusDetails: () => true,
showVerboseLog: () => true,
}),
].flatMap(({ items }) => items);
for (const spec of specs) {
const metadata = getConfig(spec.key);
expect(metadata, spec.key).not.toBe(false);
if (!metadata) {
throw new Error(`Missing setting metadata for ${spec.key}`);
}
const definition = toObsidianSettingDefinition(spec, metadata, rangeMessages);
expect(definition.name).toBe(`${metadata.name}${statusDisplay(metadata.status)}`);
}
});
});
describe("General setting specifications", () => {
it("shares every General, Logging, and File identity control between the imperative and declarative renderers", () => {
const groups = createGeneralSettingSpecGroups({
showEditorStatusDetails: () => true,
showVerboseLog: () => true,
});
expect(groups.map(({ heading }) => heading)).toEqual(["Appearance", "Logging", "File identity"]);
expect(groups.flatMap(({ items }) => items.map(({ key }) => key))).toEqual([
"displayLanguage",
"showStatusOnEditor",
"showOnlyIconsOnEditor",
"showStatusOnStatusbar",
"hideFileWarningNotice",
"syncGuardEnabled",
"syncGuardMinimumFiles",
"networkWarningStyle",
"lessInformationInLog",
"showVerboseLog",
"pathNormalizationVersion",
]);
});
it("exposes path identity for every remote type as a reconstruction-required dropdown", () => {
const spec = createGeneralSettingSpecGroups({
showEditorStatusDetails: () => false,
showVerboseLog: () => false,
})
.flatMap(({ items }) => items)
.find(({ key }) => key === "pathNormalizationVersion");
if (!spec) throw new Error("Path identity mode is not exposed on the Advanced page");
if (!spec) throw new Error("Path identity mode is not exposed in General settings");
if (spec.control.type !== "numeric-dropdown") throw new Error("Expected a numeric dropdown");
expect(spec.control).toMatchObject({ type: "numeric-dropdown", options: expect.any(Function) });
expect(spec.visible?.()).toBe(false);
couchDB = true;
expect(spec.visible?.()).toBe(true);
expect(spec).toMatchObject({ key: "pathNormalizationVersion", holdValue: true });
expect(spec.visible).toBeUndefined();
expect(spec.control.options()).toEqual({
"0": "Legacy path identity",
"1": "Unicode NFC path identity",
@@ -102,54 +149,11 @@ describe("Advanced setting specifications", () => {
key: "pathNormalizationVersion",
options: {
holdValue: true,
onUpdate: expect.any(Function),
options: { "0": "Legacy path identity", "1": "Unicode NFC path identity" },
},
});
});
it("excludes transient settings-dialogue keys from the persisted key type", () => {
type OnDialogOverlap = Extract<keyof OnDialogSettings, PersistedSettingKey>;
expectTypeOf<OnDialogOverlap>().toEqualTypeOf<never>();
});
it("resolves translated Commonlib metadata for every explicitly exposed key", () => {
const specs = createAdvancedSettingSpecGroups({ isCouchDB: () => true }).flatMap(({ items }) => items);
for (const spec of specs) {
const metadata = getConfig(spec.key);
expect(metadata, spec.key).not.toBe(false);
if (!metadata) {
throw new Error(`Missing setting metadata for ${spec.key}`);
}
const definition = toObsidianSettingDefinition(spec, metadata, rangeMessages);
expect(definition.name).toBe(`${metadata.name}${statusDisplay(metadata.status)}`);
}
});
});
describe("General setting specifications", () => {
it("shares every General and Logging control between the imperative and declarative renderers", () => {
const groups = createGeneralSettingSpecGroups({
showEditorStatusDetails: () => true,
showVerboseLog: () => true,
});
expect(groups.map(({ heading }) => heading)).toEqual(["Appearance", "Logging"]);
expect(groups.flatMap(({ items }) => items.map(({ key }) => key))).toEqual([
"displayLanguage",
"showStatusOnEditor",
"showOnlyIconsOnEditor",
"showStatusOnStatusbar",
"hideFileWarningNotice",
"syncGuardEnabled",
"syncGuardMinimumFiles",
"networkWarningStyle",
"lessInformationInLog",
"showVerboseLog",
]);
});
it("keeps the three feature-level controls together under Extra menus", () => {
const group = createExtraMenuSettingSpecGroup();
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
vi.mock("@/common/translation", () => ({
$msg: (key: string) => key,
$t: (key: string) => key,
translateLiveSyncMessage: (key: string) => key,
}));
vi.mock("./PaneChangeLog.ts", () => ({ paneChangeLog: vi.fn() }));
@@ -20,6 +21,7 @@ vi.mock("./PaneMaintenance.ts", () => ({ paneMaintenance: vi.fn() }));
import {
createAdvancedSettingDefinitionGroups,
createGeneralSettingDefinitionGroups,
createSettingsPageCatalogue,
getSettingsRootGroupEntry,
type SettingsRootGroupId,
@@ -87,7 +89,6 @@ describe("settings page catalogue", () => {
"chunkSplitterVersion",
"customChunkSize",
"strictContentCheckOnStartup",
"pathNormalizationVersion",
"readChunksOnline",
"useOnlyLocalChunk",
"concurrencyOfReadChunksOnline",
@@ -97,4 +98,22 @@ describe("settings page catalogue", () => {
]);
expect(new Set(keys).size).toBe(keys.length);
});
it("registers path identity once in General settings and leaves it out of Advanced", () => {
const generalGroups = createGeneralSettingDefinitionGroups({
showEditorStatusDetails: () => true,
showVerboseLog: () => true,
});
const generalKeys = generalGroups.flatMap(({ items = [] }) =>
items.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
);
const advancedKeys = createAdvancedSettingDefinitionGroups({ isCouchDB: () => true }).flatMap(
({ items = [] }) =>
items.flatMap((item) => ("control" in item && item.control ? [item.control.key] : []))
);
expect(generalGroups.map(({ heading }) => heading)).toContain("File identity");
expect(generalKeys.filter((key) => key === "pathNormalizationVersion")).toHaveLength(1);
expect(advancedKeys).not.toContain("pathNormalizationVersion");
});
});
+39 -12
View File
@@ -7,7 +7,7 @@ import {
REMOTE_COUCHDB,
REMOTE_MINIO,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { createNewVaultSettings } from "@/common/newVaultSettings";
import { upsertRemoteConfigurationInPlace } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { isObjectDifferent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import Intro from "./SetupWizard/dialogs/Intro.svelte";
@@ -49,6 +49,8 @@ import {
type SetupInitialisationMode,
} from "@/serviceFeatures/setupObsidian/setupActivationLifecycle.ts";
import { isP2PMainRemote } from "@/common/remoteConfiguration.ts";
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
import { resolveInitialPathIdentity } from "@/common/initialPathIdentity";
import type { P2PConnectionProbeAdmission } from "@vrtmrz/livesync-commonlib/p2p";
function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
@@ -58,11 +60,14 @@ function copySettingsForRemoteProfileUpdate(settings: ObsidianLiveSyncSettings):
};
}
function normaliseImportedIdDerivationSettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
function normaliseImportedIdentitySettings(settings: ObsidianLiveSyncSettings): ObsidianLiveSyncSettings {
// Setup URIs are complete imports even when their encoder omitted default-valued fields.
// Fill each missing half so a receiving device cannot supply the unrelated saved key.
return {
...settings,
pathNormalizationVersion: Object.prototype.hasOwnProperty.call(settings, "pathNormalizationVersion")
? settings.pathNormalizationVersion
: 0,
idDerivationVersion: Object.prototype.hasOwnProperty.call(settings, "idDerivationVersion")
? settings.idDerivationVersion
: 0,
@@ -233,7 +238,7 @@ export class SetupManager extends AbstractModule {
return false;
}
this._log("Setup URI dialog closed.", LOG_LEVEL_VERBOSE);
return await this.onConfirmApplySettingsFromWizard(normaliseImportedIdDerivationSettings(newSetting), userMode);
return await this.onConfirmApplySettingsFromWizard(normaliseImportedIdentitySettings(newSetting), userMode);
}
/**
@@ -271,6 +276,7 @@ export class SetupManager extends AbstractModule {
if (activate) {
newSetting.remoteType = REMOTE_COUCHDB;
}
if (!(await this.selectInitialPathIdentity(newSetting, userMode, activate))) return false;
upsertRemoteConfigurationInPlace(newSetting, "couchdb", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
@@ -302,10 +308,37 @@ export class SetupManager extends AbstractModule {
if (activate) {
newSetting.remoteType = REMOTE_MINIO;
}
if (!(await this.selectInitialPathIdentity(newSetting, userMode, activate))) return false;
upsertRemoteConfigurationInPlace(newSetting, "s3", { activate });
return await this.onConfirmApplySettingsFromWizard(newSetting, userMode, activate);
}
private async selectInitialPathIdentity(
settings: ObsidianLiveSyncSettings,
userMode: UserMode,
activate: boolean
): Promise<boolean> {
if (!activate || (userMode !== UserMode.NewUser && userMode !== UserMode.ExistingUser)) return true;
try {
const probe = await this.services.replicator.createRemoteResource(
REMOTE_RESOURCE_KINDS.CONNECTION,
settings
);
settings.pathNormalizationVersion = await resolveInitialPathIdentity(
probe,
settings.pathNormalizationVersion
);
return true;
} catch (error) {
this._log(
"The remote path identity could not be inspected. Settings have not been applied.",
LOG_LEVEL_NOTICE
);
this._log(error, LOG_LEVEL_VERBOSE);
return false;
}
}
/**
* Handles manual setup for P2P
* @param userMode
@@ -345,10 +378,7 @@ export class SetupManager extends AbstractModule {
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteE2EEResultType,
SetupRemoteE2EEInitialData
>(
SetupRemoteE2EE,
{ settings: currentSetting, newVault: userMode === UserMode.NewUser }
);
>(SetupRemoteE2EE, { settings: currentSetting, newVault: userMode === UserMode.NewUser });
if (e2eeConf === "cancelled") {
this._log("E2EE configuration cancelled.", LOG_LEVEL_NOTICE);
return false;
@@ -397,10 +427,7 @@ export class SetupManager extends AbstractModule {
const e2eeConf = await this.dialogManager.openWithExplicitCancel<
SetupRemoteE2EEResultType,
SetupRemoteE2EEInitialData
>(
SetupRemoteE2EE,
{ settings: originalSetting, newVault: userMode === UserMode.NewUser }
);
>(SetupRemoteE2EE, { settings: originalSetting, newVault: userMode === UserMode.NewUser });
if (e2eeConf === "cancelled") {
this._log("Manual configuration cancelled.", LOG_LEVEL_NOTICE);
return await this.onOnboard(userMode);
@@ -545,7 +572,7 @@ export class SetupManager extends AbstractModule {
async decodeQR(qr: string) {
let newSettings: ObsidianLiveSyncSettings;
try {
newSettings = normaliseImportedIdDerivationSettings(decodeSettingsFromQRCodeData(qr));
newSettings = normaliseImportedIdentitySettings(decodeSettingsFromQRCodeData(qr));
} catch {
this._log("The QR configuration could not be decoded or contains unsupported settings.", LOG_LEVEL_NOTICE);
return false;
+143 -37
View File
@@ -3,12 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { SettingService } from "@vrtmrz/livesync-commonlib/compat/services/base/SettingService";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
import { createNewVaultSettings } from "@/common/newVaultSettings";
import type {
P2PConnectionProbeAdmission,
P2PConnectionProbeAdmissionResult,
@@ -98,6 +99,12 @@ function createSetupManager() {
dialogManager,
},
setting,
replicator: {
createRemoteResource: vi.fn(async () => ({
inspectPathIdentity: vi.fn(async () => ({ status: "configured", pathNormalizationVersion: 0 })),
dispose: vi.fn(async () => undefined),
})),
},
} as any;
const core: any = {
_services: services,
@@ -165,6 +172,99 @@ describe("SetupManager", () => {
);
});
it.each([REMOTE_COUCHDB, REMOTE_MINIO])(
"uses an existing remote declaration during first manual setup for %s",
async (remoteType) => {
const { manager, core, dialogManager } = createSetupManager();
const dispose = vi.fn(async () => undefined);
core.services.replicator.createRemoteResource.mockResolvedValue({
inspectPathIdentity: vi.fn(async () => ({ status: "configured", pathNormalizationVersion: 0 })),
dispose,
});
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
dialogManager.openWithExplicitCancel.mockResolvedValue({
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
couchDB_USER: "user",
couchDB_PASSWORD: "pass",
endpoint: "https://objects.test",
bucket: "vault",
accessKey: "key",
secretKey: "secret",
region: "auto",
bucketPrefix: "",
});
const initial = { ...createLegacyRemoteSetting(), pathNormalizationVersion: 1 } as ObsidianLiveSyncSettings;
if (remoteType === REMOTE_COUCHDB) await manager.onCouchDBManualSetup(UserMode.NewUser, initial);
else await manager.onBucketManualSetup(UserMode.NewUser, initial);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({ pathNormalizationVersion: 0 }),
UserMode.NewUser,
true
);
expect(dispose).toHaveBeenCalledOnce();
expect(initial.pathNormalizationVersion).toBe(1);
}
);
it.each([
[{ status: "empty" }, 1],
[{ status: "configured", pathNormalizationVersion: 1 }, 1],
] as const)("uses inspected setup identity %s", async (inspection, expected) => {
const { manager, core, dialogManager } = createSetupManager();
core.services.replicator.createRemoteResource.mockResolvedValue({
inspectPathIdentity: vi.fn(async () => inspection),
dispose: vi.fn(async () => undefined),
});
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
dialogManager.openWithExplicitCancel.mockResolvedValue({
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
});
await manager.onCouchDBManualSetup(UserMode.NewUser, createNewVaultSettings());
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({ pathNormalizationVersion: expected }),
UserMode.NewUser,
true
);
});
it("does not apply setup settings when identity inspection fails", async () => {
const { manager, core, dialogManager, setting } = createSetupManager();
const failure = new Error("remote unavailable");
const dispose = vi.fn(async () => undefined);
core.services.replicator.createRemoteResource.mockResolvedValue({
inspectPathIdentity: vi.fn(async () => {
throw failure;
}),
dispose,
});
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
dialogManager.openWithExplicitCancel.mockResolvedValue({
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
});
await expect(manager.onCouchDBManualSetup(UserMode.NewUser, createNewVaultSettings())).resolves.toBe(false);
expect(confirm).not.toHaveBeenCalled();
expect(setting.saveSettingData).not.toHaveBeenCalled();
expect(dispose).toHaveBeenCalledOnce();
});
it.each([
[UserMode.Update, true],
[UserMode.NewUser, false],
] as const)("keeps identity during profile edits in mode %s with activation %s", async (mode, activate) => {
const { manager, core, dialogManager } = createSetupManager();
const confirm = vi.spyOn(manager, "onConfirmApplySettingsFromWizard").mockResolvedValue(true);
dialogManager.openWithExplicitCancel.mockResolvedValue({
couchDB_URI: "https://example.test",
couchDB_DBNAME: "vault",
});
await manager.onCouchDBManualSetup(mode, createLegacyRemoteSetting(), activate);
expect(core.services.replicator.createRemoteResource).not.toHaveBeenCalled();
expect(confirm).toHaveBeenCalledWith(expect.objectContaining({ pathNormalizationVersion: 0 }), mode, activate);
});
it("starts manual new-user setup from the recommended new-Vault settings", async () => {
const { manager, dialogManager } = createSetupManager();
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("configure-manually");
@@ -192,12 +292,13 @@ describe("SetupManager", () => {
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
});
it("compatibility: treats omitted ID derivation fields in a Setup URI as legacy defaults", async () => {
it("compatibility: treats omitted identity fields in a Setup URI as legacy defaults", async () => {
const { manager, setting, dialogManager } = createSetupManager();
const savedKey = "12".repeat(32);
setting.settings = {
...createLegacyRemoteSetting(),
isConfigured: true,
pathNormalizationVersion: 1,
idDerivationVersion: 1,
idDerivationKey: savedKey,
};
@@ -205,6 +306,7 @@ describe("SetupManager", () => {
...createLegacyRemoteSetting(),
isConfigured: true,
} as Partial<ObsidianLiveSyncSettings>;
delete imported.pathNormalizationVersion;
delete imported.idDerivationVersion;
delete imported.idDerivationKey;
vi.spyOn(setting, "adjustSettings").mockImplementation((settings) => Promise.resolve(settings));
@@ -213,6 +315,7 @@ describe("SetupManager", () => {
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://legacy-settings");
const mergedSettings = vi.mocked(setting.adjustSettings).mock.calls[0][0];
expect(mergedSettings.pathNormalizationVersion).toBe(0);
expect(mergedSettings.idDerivationVersion).toBe(0);
expect(mergedSettings.idDerivationKey).toBe("");
expect(setting.currentSettings().idDerivationKey).toBe(savedKey);
@@ -489,44 +592,47 @@ describe("SetupManager", () => {
it.each([
{ importedSize: 0, expectedSize: 0 },
{ importedSize: undefined, expectedSize: 60 },
])("preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize", async ({ importedSize, expectedSize }) => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
customChunkSize: 60,
};
const imported = {
...DEFAULT_SETTINGS,
remoteConfigurations: {
couch: {
id: "couch",
name: "Office CouchDB",
uri: "sls+https://alice:secret@couch.example/?db=notes",
isEncrypted: false,
])(
"preserves modern Setup URI profiles and adopts the supplied chunk size $importedSize",
async ({ importedSize, expectedSize }) => {
const { manager, setting, dialogManager } = createSetupManager();
setting.settings = {
...setting.currentSettings(),
customChunkSize: 60,
};
const imported = {
...DEFAULT_SETTINGS,
remoteConfigurations: {
couch: {
id: "couch",
name: "Office CouchDB",
uri: "sls+https://alice:secret@couch.example/?db=notes",
isEncrypted: false,
},
archive: {
id: "archive",
name: "Archive bucket",
uri: "sls+s3://key:secret@storage.example/?endpoint=https%3A%2F%2Fstorage.example&bucket=archive&region=auto",
isEncrypted: false,
},
},
archive: {
id: "archive",
name: "Archive bucket",
uri: "sls+s3://key:secret@storage.example/?endpoint=https%3A%2F%2Fstorage.example&bucket=archive&region=auto",
isEncrypted: false,
},
},
activeConfigurationId: "archive",
} as ObsidianLiveSyncSettings;
if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
else imported.customChunkSize = importedSize;
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce(imported)
.mockResolvedValueOnce("compatible-existing-user");
activeConfigurationId: "archive",
} as ObsidianLiveSyncSettings;
if (importedSize === undefined) delete (imported as Partial<ObsidianLiveSyncSettings>).customChunkSize;
else imported.customChunkSize = importedSize;
dialogManager.openWithExplicitCancel
.mockResolvedValueOnce(imported)
.mockResolvedValueOnce("compatible-existing-user");
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
await manager.onUseSetupURI(UserMode.Unknown, "mock-config://modern-settings");
const current = setting.currentSettings();
expect(current.customChunkSize).toBe(expectedSize);
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
expect(current.activeConfigurationId).toBe("archive");
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
});
const current = setting.currentSettings();
expect(current.customChunkSize).toBe(expectedSize);
expect(current.remoteConfigurations).toEqual(imported.remoteConfigurations);
expect(current.activeConfigurationId).toBe("archive");
expect(Object.keys(current.remoteConfigurations).some((id) => id.startsWith("legacy-"))).toBe(false);
}
);
it("adds and activates a manually configured CouchDB without replacing existing profiles", async () => {
const { manager, setting, dialogManager } = createSetupManager();