mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-08 19:57:06 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1dbd3eeb3 | ||
|
|
78c2ccc15a |
@@ -138,6 +138,10 @@ For file-event admission versus physical Vault writes, see
|
||||
and its linked Commonlib contract. Keep regression coverage for those two
|
||||
directions separate when changing deletion handling.
|
||||
|
||||
For shared synchronisation-setting comparisons, directional reconstruction
|
||||
consequences, and the lifetime of a recovery decision, see
|
||||
[Tweak compatibility and recovery](docs/design_docs/tweak_compatibility.md).
|
||||
|
||||
### Service composition and legacy Modules
|
||||
|
||||
The application is composed from Services, ServiceModules, serviceFeatures, add-ons, and a legacy Module layer:
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
date: 2026-09-08
|
||||
commonlib-version: "0.1.24"
|
||||
self-hosted-livesync-version: "1.0.27"
|
||||
status: unreleased
|
||||
---
|
||||
|
||||
# Tweak compatibility and recovery
|
||||
|
||||
This document describes the integration of Commonlib 0.1.24 with
|
||||
Self-hosted LiveSync 1.0.27. Commonlib owns the interpretation of synchronisation
|
||||
settings; LiveSync owns the dialogues and operations which consume that result.
|
||||
|
||||
## Shared assessment
|
||||
|
||||
`assessTweakCompatibility`, exported by Commonlib's `settings` entry point,
|
||||
compares one snapshot of current settings with one snapshot of preferred settings.
|
||||
The result contains the original values, effective values, differences, and
|
||||
reconstruction consequences for each direction of adoption. It performs no
|
||||
settings writes, translation, network requests, or database operations.
|
||||
|
||||
The interpretation of a missing value is specific to its setting. A missing
|
||||
`handleFilenameCaseSensitive` means `false`, matching legacy conversion from paths to
|
||||
document IDs. An explicitly enabled value is therefore different from either an
|
||||
explicitly disabled value or a missing value. Settings whose historical missing
|
||||
value has not been established remain unadvertised; the evaluator does not
|
||||
invent defaults or turn every falsy value into an absent value.
|
||||
|
||||
The central replication gate, mismatch dialogues, and RedFlag Fetch preparation
|
||||
consume the same effective differences. The P2P transport retains its separate
|
||||
policy: ordinary representation differences warn without rejecting transfer,
|
||||
while its existing passphrase and peer checks still apply. Sharing assessment
|
||||
does not make the central replication policy appropriate for every transport.
|
||||
|
||||
## Host responsibilities
|
||||
|
||||
`ModuleResolvingMismatchedTweaks` renders the assessment supplied by the failed
|
||||
attempt. A legacy recovery hint without an assessment is adapted through the
|
||||
same Commonlib function. The remote profile review uses the trial settings as
|
||||
its current snapshot, including when deciding whether compatible chunk settings
|
||||
can be accepted automatically.
|
||||
|
||||
Each adoption direction has its own reconstruction consequence. The remote
|
||||
values becoming local values can require local Fetch; the local values becoming
|
||||
preferred remote values can require remote Rebuild. The host must not infer the
|
||||
second consequence from the first. Existing explicit Fetch choices and manual
|
||||
acceptance controls remain host decisions.
|
||||
|
||||
The common assessment identifies whether every known difference qualifies for
|
||||
automatic alignment. LiveSync retains the opt-out and modification-time policy
|
||||
which chooses a side. An unadvertised setting does not expand automatic
|
||||
alignment to a case whose effect cannot be assessed.
|
||||
|
||||
Only defined, permitted settings are applied. A partial preferred configuration
|
||||
must not erase a local value with `undefined`. Recommended settings outside the
|
||||
set of settings which must match retain their existing adoption behaviour; RedFlag Fetch
|
||||
continues to apply only the set of settings which must match. RedFlag Rebuild remains
|
||||
authoritative from this device and does not adopt the remote configuration.
|
||||
|
||||
## Decision lifetime
|
||||
|
||||
An assessment describes one pair of inputs; it does not authorise a later
|
||||
write. LiveSync checks the settings and active publication again after waiting
|
||||
for a decision and before applying it. A changed target or changed settings
|
||||
discard that decision. The signature used for this check can contain sensitive
|
||||
configuration and must not be logged, persisted, or included in diagnostics.
|
||||
|
||||
The active publication reservation is not held while waiting for the dialogue.
|
||||
Remote writes use the failed attempt's publication guard. Fetch and Rebuild use
|
||||
their existing owners and propagate failure without claiming a successful
|
||||
retry. A subsequent attempt must use fresh settings and respect publication
|
||||
replacement rather than reusing the rejected attempt's settings snapshot.
|
||||
|
||||
Ordinary typed OneShot replication retains the failed outcome after its recovery
|
||||
dialogue; the next synchronisation request is a separate attempt. Directional
|
||||
replication can retry once after `CHECKAGAIN`, using freshly captured settings
|
||||
and the same publication guard. Setting adoption does not turn the original
|
||||
failed transfer into a completed transfer.
|
||||
|
||||
## Verification boundaries
|
||||
|
||||
Commonlib tests protect missing-value interpretation, representation differences,
|
||||
directional consequences, immutable results, central admission, and P2P policy.
|
||||
LiveSync unit tests protect ordinary versus reconstruction choices, trial-setting
|
||||
selection, partial-setting adoption, and invalidated decisions. RedFlag tests
|
||||
protect the distinction between adopting settings for Fetch and retaining local
|
||||
settings for Rebuild.
|
||||
|
||||
Real-runtime verification must separately cover setting adoption, actual
|
||||
replication, explicit Fetch, and replication after restart. A unit test which
|
||||
mocks Fetch does not establish those behaviours, and a corrected mismatch
|
||||
dialogue alone does not establish the cause of a reported persistent automatic
|
||||
synchronisation failure.
|
||||
@@ -88,6 +88,8 @@ Some settings must match across devices. LiveSync pauses synchronisation when th
|
||||
|
||||
Current releases automatically align compatible settings which control how new chunks are created, by default and where possible. This applies to the chunk hash algorithm, chunk size, and splitter version. Existing content remains readable across these choices, although using different choices can reduce chunk reuse and increase storage or transfer work. An explicit opt-out retains the manual review. A mismatch involving encryption, path obfuscation, file-name case handling, or any combination which includes one of those settings always remains a manual decision.
|
||||
|
||||
A missing legacy file-name case setting means case-insensitive handling. It matches an explicit disabled setting and does not require a rebuild for that difference. An explicitly enabled setting can use different document IDs and still requires a compatibility decision against either value. Other configuration differences shown in the dialogue must still be resolved.
|
||||
|
||||
The `Sync now` command keeps routine replication progress quiet so that it is convenient to assign to a keyboard shortcut; assign one in Obsidian if that suits your workflow. A quiet command may still open this dialogue when a mismatch or another decision requires your attention.
|
||||
|
||||
The available actions depend on when the mismatch is found:
|
||||
|
||||
Generated
+4
-4
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.23",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.24",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -4567,9 +4567,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.23",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.23.tgz",
|
||||
"integrity": "sha512-hsaz2N04qNqM9HL0B+d5G/do1T0fe6Y4gVK3IueXvEnM6HM+3Jp17mdjbLn93FUOxkUVMcn4M+zIwPuppryVbw==",
|
||||
"version": "0.1.24",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.24.tgz",
|
||||
"integrity": "sha512-gOXKo3ptEUYDkjLd5PGq2vAhOSvxOqW6xE7YWo9Y8ienglfYBz8R3eZ0I/JruvwZltH2B7Bmi41pHMjmRJQe3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
|
||||
+2
-1
@@ -66,6 +66,7 @@
|
||||
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
|
||||
"test:e2e:obsidian:vault-reflection": "tsx test/e2e-obsidian/scripts/vault-reflection.ts",
|
||||
"test:e2e:obsidian:couchdb-upload": "tsx test/e2e-obsidian/scripts/couchdb-upload.ts",
|
||||
"test:e2e:obsidian:tweak-compatibility": "tsx test/e2e-obsidian/scripts/tweak-compatibility.ts",
|
||||
"test:e2e:obsidian:couchdb-manual-setup-workflow": "tsx test/e2e-obsidian/scripts/couchdb-manual-setup-workflow.ts",
|
||||
"test:e2e:obsidian:cli-to-obsidian-sync": "tsx test/e2e-obsidian/scripts/cli-to-obsidian-sync.ts",
|
||||
"test:e2e:obsidian:minio-upload": "tsx test/e2e-obsidian/scripts/minio-upload.ts",
|
||||
@@ -180,7 +181,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.23",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.24",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { Logger, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import {
|
||||
TweakValuesShouldMatchedTemplate,
|
||||
TweakValuesTemplate,
|
||||
IncompatibleChanges,
|
||||
configurationNames,
|
||||
statusDisplay,
|
||||
type TweakValues,
|
||||
type ObsidianLiveSyncSettings,
|
||||
type RemoteDBSettings,
|
||||
IncompatibleChangesInSpecificPattern,
|
||||
CompatibleButLossyChanges,
|
||||
type RemotePreferredTweakResult,
|
||||
RemotePreferredTweakStatuses,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { assessTweakCompatibility, type TweakAssessment } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { escapeMarkdownValue } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { AbstractModule } from "@/modules/AbstractModule.ts";
|
||||
import { $msg, translateIfAvailable } from "@/common/translation";
|
||||
@@ -59,14 +56,49 @@ function valueToString(value: string | number | boolean | object | undefined): s
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
private _collectMismatchedTweakKeys(current: TweakValues, preferred: Partial<TweakValues>) {
|
||||
const items = Object.keys(
|
||||
TweakValuesShouldMatchedTemplate
|
||||
) as (keyof typeof TweakValuesShouldMatchedTemplate)[];
|
||||
return items.filter((key) => current[key] !== preferred[key]);
|
||||
}
|
||||
function definedTweaks(values: TweakValues): TweakValues {
|
||||
return Object.fromEntries(
|
||||
Object.entries(values).filter(([key, value]) => key in TweakValuesTemplate && value !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function settingsAfterAdoption(assessment: TweakAssessment, direction: "adoptPreferred" | "adoptCurrent"): TweakValues {
|
||||
const comparedKeys = new Set<string>(assessment.entries.map((entry) => entry.key));
|
||||
const source = direction === "adoptPreferred" ? assessment.preferredValues : assessment.currentValues;
|
||||
const target = direction === "adoptPreferred" ? assessment.currentValues : assessment.preferredValues;
|
||||
const recommendations = Object.fromEntries(
|
||||
Object.entries(source).filter(([key, value]) => !comparedKeys.has(key) && value !== undefined)
|
||||
);
|
||||
return {
|
||||
...definedTweaks(target),
|
||||
...recommendations,
|
||||
...assessment[direction].changes,
|
||||
};
|
||||
}
|
||||
|
||||
function mismatchTable(assessment: TweakAssessment, direction?: "adoptPreferred" | "adoptCurrent"): string {
|
||||
const reasons = direction
|
||||
? assessment[direction].reasons
|
||||
: [...assessment.adoptPreferred.reasons, ...assessment.adoptCurrent.reasons];
|
||||
const consequenceKeys = new Set(reasons.map((reason) => reason.key));
|
||||
const rows = assessment.entries
|
||||
.filter((entry) => entry.relation === "different" || consequenceKeys.has(entry.key))
|
||||
.map((entry) =>
|
||||
$msg("TweakMismatchResolve.Table.Row", {
|
||||
name: localisedConfName(entry.key),
|
||||
self: valueToString(escapeMarkdownValue(entry.current.effectiveValue)),
|
||||
remote: valueToString(escapeMarkdownValue(entry.preferred.effectiveValue)),
|
||||
})
|
||||
);
|
||||
return $msg("TweakMismatchResolve.Table", { rows: rows.join("\n") });
|
||||
}
|
||||
|
||||
/** Kept only while resolving a decision; this can contain credentials and must never be logged. */
|
||||
function resolutionSettingsSignature(settings: ObsidianLiveSyncSettings): string {
|
||||
return JSON.stringify({ ...settings, autoAcceptCompatibleTweak: settings.autoAcceptCompatibleTweak ?? true });
|
||||
}
|
||||
|
||||
export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
private _selectNewerTweakSide(current: TweakValues, preferred: Partial<TweakValues>): "REMOTE" | "CURRENT" {
|
||||
Logger(`Modified: ${current.tweakModified} (current) vs ${preferred.tweakModified} (preferred)`);
|
||||
const currentModified = current.tweakModified;
|
||||
@@ -83,15 +115,9 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
}
|
||||
|
||||
private async _shouldAutoAcceptCompatibleLossy(
|
||||
current: TweakValues,
|
||||
preferred: Partial<TweakValues>,
|
||||
mismatchedKeys: (keyof typeof TweakValuesShouldMatchedTemplate)[]
|
||||
assessment: TweakAssessment
|
||||
): Promise<"REMOTE" | "CURRENT" | undefined> {
|
||||
if (mismatchedKeys.length === 0) return undefined;
|
||||
const hasOnlyCompatibleLossyMismatches = mismatchedKeys.every(
|
||||
(key) => CompatibleButLossyChanges.indexOf(key) !== -1
|
||||
);
|
||||
if (!hasOnlyCompatibleLossyMismatches) return undefined;
|
||||
if (!assessment.onlyCompatibleLossyDifferences) return undefined;
|
||||
|
||||
let autoAcceptCompatibleTweak = this.settings.autoAcceptCompatibleTweak;
|
||||
if (this.settings.autoAcceptCompatibleTweak === undefined) {
|
||||
@@ -104,7 +130,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
}
|
||||
|
||||
if (autoAcceptCompatibleTweak !== true) return undefined;
|
||||
return this._selectNewerTweakSide(current, preferred);
|
||||
return this._selectNewerTweakSide(assessment.currentValues, assessment.preferredValues);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +164,14 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const isCurrent = await this.services.replicator.runWithActiveReplicatorContext(
|
||||
(activeContext) => activeContext === failure.context
|
||||
);
|
||||
if (!isCurrent || resolutionSettingsSignature(failure.setting) !== resolutionSettingsSignature(this.settings)) {
|
||||
return true;
|
||||
}
|
||||
const assessment =
|
||||
recovery.tweakAssessment ?? assessTweakCompatibility(failure.setting, recovery.preferredTweakValue);
|
||||
const ret = await this.services.tweakValue.askResolvingMismatched(
|
||||
{ ...recovery.preferredTweakValue },
|
||||
async (setting) => {
|
||||
@@ -149,138 +183,119 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
updated = true;
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
},
|
||||
assessment
|
||||
);
|
||||
if (ret == "OK") return false;
|
||||
if (ret == "CHECKAGAIN") return "CHECKAGAIN";
|
||||
if (ret == "IGNORE") return true;
|
||||
}
|
||||
|
||||
async _checkAndAskResolvingMismatchedTweaks(preferred: TweakValues): Promise<[TweakValues | boolean, boolean]> {
|
||||
const mine = extractObject(TweakValuesTemplate, this.settings) as TweakValues;
|
||||
const mismatchedKeys = this._collectMismatchedTweakKeys(mine, preferred);
|
||||
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(mine, preferred, mismatchedKeys);
|
||||
if (autoAcceptSide === "REMOTE") {
|
||||
return [{ ...mine, ...preferred }, false];
|
||||
}
|
||||
if (autoAcceptSide === "CURRENT") {
|
||||
return [true, false];
|
||||
}
|
||||
const items = Object.entries(TweakValuesShouldMatchedTemplate);
|
||||
let rebuildRequired = false;
|
||||
let rebuildRecommended = false;
|
||||
// Making tables:
|
||||
// let table = `| Value name | This device | Configured | \n` + `|: --- |: --- :|: ---- :| \n`;
|
||||
const tableRows = [];
|
||||
// const items = [mine,preferred]
|
||||
for (const v of items) {
|
||||
const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
|
||||
const valueMine = escapeMarkdownValue(mine[key]);
|
||||
const valuePreferred = escapeMarkdownValue(preferred[key]);
|
||||
if (valueMine == valuePreferred) continue;
|
||||
if (IncompatibleChanges.indexOf(key) !== -1) {
|
||||
rebuildRequired = true;
|
||||
}
|
||||
for (const pattern of IncompatibleChangesInSpecificPattern) {
|
||||
if (pattern.key !== key) continue;
|
||||
// if from value supplied, check if current value have been violated : in other words, if the current value is the same as the from value, it should require a rebuild.
|
||||
const isFromConditionMet = "from" in pattern ? pattern.from === mine[key] : false;
|
||||
// and, if to value supplied, same as above.
|
||||
const isToConditionMet = "to" in pattern ? pattern.to === preferred[key] : false;
|
||||
// if either of them is true, it should require a rebuild, if the pattern is not a recommendation.
|
||||
if (isFromConditionMet || isToConditionMet) {
|
||||
if (pattern.isRecommendation) {
|
||||
rebuildRecommended = true;
|
||||
} else {
|
||||
rebuildRequired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (CompatibleButLossyChanges.indexOf(key) !== -1) {
|
||||
rebuildRecommended = true;
|
||||
}
|
||||
|
||||
// table += `| ${confName(key)} | ${valueMine} | ${valuePreferred} | \n`;
|
||||
tableRows.push(
|
||||
$msg("TweakMismatchResolve.Table.Row", {
|
||||
name: localisedConfName(key),
|
||||
self: valueToString(valueMine),
|
||||
remote: valueToString(valuePreferred),
|
||||
})
|
||||
);
|
||||
}
|
||||
async _checkAndAskResolvingMismatchedTweaks(
|
||||
preferred: TweakValues,
|
||||
assessment = assessTweakCompatibility(this.settings, preferred)
|
||||
): Promise<[TweakValues | boolean, boolean]> {
|
||||
if (assessment.alignment === "matched") return [false, false];
|
||||
const acceptedSettings = settingsAfterAdoption(assessment, "adoptPreferred");
|
||||
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(assessment);
|
||||
if (autoAcceptSide === "REMOTE") return [acceptedSettings, false];
|
||||
if (autoAcceptSide === "CURRENT") return [true, false];
|
||||
|
||||
const localImpact = assessment.adoptPreferred.reconstruction;
|
||||
const remoteImpact = assessment.adoptCurrent.reconstruction;
|
||||
const requiresRebuild = localImpact === "required" || remoteImpact === "required";
|
||||
const recommendsRebuild = localImpact === "recommended" || remoteImpact === "recommended";
|
||||
const additionalMessage =
|
||||
rebuildRequired && this.core.settings.isConfigured
|
||||
requiresRebuild && this.settings.isConfigured
|
||||
? $msg("TweakMismatchResolve.Message.WarningIncompatibleRebuildRequired")
|
||||
: "";
|
||||
const additionalMessage2 =
|
||||
rebuildRecommended && this.core.settings.isConfigured
|
||||
recommendsRebuild && this.settings.isConfigured
|
||||
? $msg("TweakMismatchResolve.Message.WarningIncompatibleRebuildRecommended")
|
||||
: "";
|
||||
|
||||
const table = $msg("TweakMismatchResolve.Table", { rows: tableRows.join("\n") });
|
||||
|
||||
const message = $msg("TweakMismatchResolve.Message.MainTweakResolving", {
|
||||
table: table,
|
||||
additionalMessage: [additionalMessage, additionalMessage2].filter((v) => v).join("\n"),
|
||||
table: mismatchTable(assessment),
|
||||
additionalMessage: [additionalMessage, additionalMessage2].filter(Boolean).join("\n"),
|
||||
});
|
||||
|
||||
const CHOICE_USE_REMOTE = $msg("TweakMismatchResolve.Action.UseRemote");
|
||||
const CHOICE_USE_REMOTE_WITH_REBUILD = $msg("TweakMismatchResolve.Action.UseRemoteWithRebuild");
|
||||
const CHOICE_USE_REMOTE_PREVENT_REBUILD = $msg("TweakMismatchResolve.Action.UseRemoteAcceptIncompatible");
|
||||
const CHOICE_USE_MINE = $msg("TweakMismatchResolve.Action.UseMine");
|
||||
const CHOICE_USE_MINE_WITH_REBUILD = $msg("TweakMismatchResolve.Action.UseMineWithRebuild");
|
||||
const CHOICE_USE_MINE_PREVENT_REBUILD = $msg("TweakMismatchResolve.Action.UseMineAcceptIncompatible");
|
||||
const CHOICE_DISMISS = $msg("TweakMismatchResolve.Action.Dismiss");
|
||||
|
||||
const CHOICE_AND_VALUES = [] as [string, [result: TweakValues | boolean, rebuild: boolean]][];
|
||||
|
||||
if (rebuildRequired) {
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [preferred, true]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_MINE_WITH_REBUILD, [true, true]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_PREVENT_REBUILD, [preferred, false]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_MINE_PREVENT_REBUILD, [true, false]]);
|
||||
} else if (rebuildRecommended) {
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_MINE, [true, false]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE_WITH_REBUILD, [preferred, true]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_MINE_WITH_REBUILD, [true, true]]);
|
||||
} else {
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_REMOTE, [preferred, false]]);
|
||||
CHOICE_AND_VALUES.push([CHOICE_USE_MINE, [true, false]]);
|
||||
const choices: Record<string, [TweakValues | boolean, boolean]> = {};
|
||||
const remoteChoices = {
|
||||
ordinary: $msg("TweakMismatchResolve.Action.UseRemote"),
|
||||
rebuild: $msg("TweakMismatchResolve.Action.UseRemoteWithRebuild"),
|
||||
accept: $msg("TweakMismatchResolve.Action.UseRemoteAcceptIncompatible"),
|
||||
};
|
||||
const localChoices = {
|
||||
ordinary: $msg("TweakMismatchResolve.Action.UseMine"),
|
||||
rebuild: $msg("TweakMismatchResolve.Action.UseMineWithRebuild"),
|
||||
accept: $msg("TweakMismatchResolve.Action.UseMineAcceptIncompatible"),
|
||||
};
|
||||
// Each direction owns its consequence; a rebuild on one side does not require one on the other.
|
||||
choices[localImpact === "required" ? remoteChoices.rebuild : remoteChoices.ordinary] = [
|
||||
acceptedSettings,
|
||||
localImpact === "required",
|
||||
];
|
||||
choices[remoteImpact === "required" ? localChoices.rebuild : localChoices.ordinary] = [
|
||||
true,
|
||||
remoteImpact === "required",
|
||||
];
|
||||
if (localImpact !== "none") {
|
||||
choices[localImpact === "required" ? remoteChoices.accept : remoteChoices.rebuild] = [
|
||||
acceptedSettings,
|
||||
localImpact !== "required",
|
||||
];
|
||||
}
|
||||
CHOICE_AND_VALUES.push([CHOICE_DISMISS, [false, false]]);
|
||||
const CHOICES = Object.fromEntries(CHOICE_AND_VALUES) as Record<
|
||||
string,
|
||||
[TweakValues | boolean, performRebuild: boolean]
|
||||
>;
|
||||
const retKey = await this.core.confirm.askSelectStringDialogue(message, Object.keys(CHOICES), {
|
||||
if (remoteImpact !== "none") {
|
||||
choices[remoteImpact === "required" ? localChoices.accept : localChoices.rebuild] = [
|
||||
true,
|
||||
remoteImpact !== "required",
|
||||
];
|
||||
}
|
||||
const dismiss = $msg("TweakMismatchResolve.Action.Dismiss");
|
||||
choices[dismiss] = [false, false];
|
||||
const retKey = await this.core.confirm.askSelectStringDialogue(message, Object.keys(choices), {
|
||||
title: $msg("TweakMismatchResolve.Title.TweakResolving"),
|
||||
timeout: 60,
|
||||
defaultAction: CHOICE_DISMISS,
|
||||
defaultAction: dismiss,
|
||||
});
|
||||
if (!retKey) return [false, false];
|
||||
return CHOICES[retKey];
|
||||
return (retKey && choices[retKey]) || [false, false];
|
||||
}
|
||||
|
||||
async _askResolvingMismatchedTweaks(
|
||||
preferredSource: TweakValues,
|
||||
updatePreferredRemote?: (setting: ObsidianLiveSyncSettings) => Promise<boolean>
|
||||
updatePreferredRemote?: (setting: ObsidianLiveSyncSettings) => Promise<boolean>,
|
||||
assessment = assessTweakCompatibility(this.settings, preferredSource)
|
||||
): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
||||
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(preferredSource);
|
||||
const signature = resolutionSettingsSignature(this.settings);
|
||||
const publication = await this.services.replicator.acquireActiveReplicatorContext();
|
||||
if (resolutionSettingsSignature(this.settings) !== signature) return "IGNORE";
|
||||
const currentTweaks = JSON.stringify(extractObject(TweakValuesTemplate, this.settings));
|
||||
if (JSON.stringify(extractObject(TweakValuesTemplate, assessment.currentValues)) !== currentTweaks) {
|
||||
return "IGNORE";
|
||||
}
|
||||
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(
|
||||
preferredSource,
|
||||
assessment
|
||||
);
|
||||
if (!conf) return "IGNORE";
|
||||
const currentPublication = await this.services.replicator.acquireActiveReplicatorContext();
|
||||
if (currentPublication !== publication || resolutionSettingsSignature(this.settings) !== signature) {
|
||||
return "IGNORE";
|
||||
}
|
||||
|
||||
const updateRemote = async () => {
|
||||
if (updatePreferredRemote) return await updatePreferredRemote(this.settings);
|
||||
const updateRemote = async (tweaks: TweakValues) => {
|
||||
const setting = {
|
||||
...this.settings,
|
||||
...definedTweaks(assessment.preferredValues),
|
||||
...definedTweaks(tweaks),
|
||||
};
|
||||
if (updatePreferredRemote) return await updatePreferredRemote(setting);
|
||||
const candidate = this.core.replicator;
|
||||
if (typeof candidate.setPreferredRemoteTweakSettings !== "function") return false;
|
||||
await candidate.setPreferredRemoteTweakSettings(this.settings);
|
||||
await candidate.setPreferredRemoteTweakSettings(setting);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (conf === true) {
|
||||
if (!(await updateRemote())) return "IGNORE";
|
||||
if (!(await updateRemote(settingsAfterAdoption(assessment, "adoptCurrent")))) return "IGNORE";
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$rebuildRemote();
|
||||
}
|
||||
@@ -288,16 +303,15 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
return "CHECKAGAIN";
|
||||
}
|
||||
if (conf) {
|
||||
// ReplicationService retains the current settings object while it performs the immediate
|
||||
// CHECKAGAIN retry. Update that object in place so the retry observes the accepted values.
|
||||
Object.assign(this.settings, extractObject(TweakValuesTemplate, conf));
|
||||
// Keep existing consumers' settings reference stable, and never erase a value omitted by an older peer.
|
||||
Object.assign(this.settings, definedTweaks(conf));
|
||||
await this.services.setting.saveSettingData();
|
||||
if (!rebuildRequired) {
|
||||
// The failed replication has settled before mismatch resolution runs. Reinitialise the
|
||||
// chunk-generation managers now so hash and splitter changes take effect before retrying.
|
||||
await this.localDatabase.managers.reinitialise();
|
||||
}
|
||||
if (!(await updateRemote())) return "IGNORE";
|
||||
if (!(await updateRemote(this.settings))) return "IGNORE";
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$fetchLocal();
|
||||
}
|
||||
@@ -333,7 +347,9 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
if (trialSetting.remoteType === REMOTE_P2P) {
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
const signature = JSON.stringify(trialSetting);
|
||||
const preferred = await this.services.tweakValue.fetchRemotePreferred(trialSetting);
|
||||
if (JSON.stringify(trialSetting) !== signature) return { result: false, requireFetch: false };
|
||||
if (preferred.status === RemotePreferredTweakStatuses.AVAILABLE) {
|
||||
return await this.services.tweakValue.askUseRemoteConfiguration(trialSetting, preferred.values);
|
||||
}
|
||||
@@ -344,101 +360,48 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
trialSetting: RemoteDBSettings,
|
||||
preferred: TweakValues
|
||||
): Promise<{ result: false | TweakValues; requireFetch: boolean }> {
|
||||
const localTweaks = extractObject(TweakValuesTemplate, this.settings) as TweakValues;
|
||||
const mismatchedKeys = this._collectMismatchedTweakKeys(localTweaks, preferred);
|
||||
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(localTweaks, preferred, mismatchedKeys);
|
||||
if (autoAcceptSide === "REMOTE") {
|
||||
return { result: { ...trialSetting, ...preferred }, requireFetch: false };
|
||||
}
|
||||
if (autoAcceptSide === "CURRENT") {
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
|
||||
const items = Object.entries(TweakValuesShouldMatchedTemplate);
|
||||
let rebuildRequired = false;
|
||||
let rebuildRecommended = false;
|
||||
// Making tables:
|
||||
// let table = `| Value name | This device | On Remote | \n` + `|: --- |: ---- :|: ---- :| \n`;
|
||||
let differenceCount = 0;
|
||||
const tableRows = [] as string[];
|
||||
// const items = [mine,preferred]
|
||||
for (const v of items) {
|
||||
const key = v[0] as keyof typeof TweakValuesShouldMatchedTemplate;
|
||||
const remoteValueForDisplay = escapeMarkdownValue(valueToString(preferred[key]));
|
||||
const currentValueForDisplay = escapeMarkdownValue(valueToString((trialSetting as TweakValues)?.[key]));
|
||||
if ((trialSetting as TweakValues)?.[key] !== preferred[key]) {
|
||||
if (IncompatibleChanges.indexOf(key) !== -1) {
|
||||
rebuildRequired = true;
|
||||
}
|
||||
for (const pattern of IncompatibleChangesInSpecificPattern) {
|
||||
if (pattern.key !== key) continue;
|
||||
// if from value supplied, check if current value have been violated : in other words, if the current value is the same as the from value, it should require a rebuild.
|
||||
const isFromConditionMet =
|
||||
"from" in pattern ? pattern.from === (trialSetting as TweakValues)?.[key] : false;
|
||||
// and, if to value supplied, same as above.
|
||||
const isToConditionMet = "to" in pattern ? pattern.to === preferred[key] : false;
|
||||
// if either of them is true, it should require a rebuild, if the pattern is not a recommendation.
|
||||
if (isFromConditionMet || isToConditionMet) {
|
||||
if (pattern.isRecommendation) {
|
||||
rebuildRecommended = true;
|
||||
} else {
|
||||
rebuildRequired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (CompatibleButLossyChanges.indexOf(key) !== -1) {
|
||||
rebuildRecommended = true;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
tableRows.push(
|
||||
$msg("TweakMismatchResolve.Table.Row", {
|
||||
name: localisedConfName(key),
|
||||
self: currentValueForDisplay,
|
||||
remote: remoteValueForDisplay,
|
||||
})
|
||||
);
|
||||
differenceCount++;
|
||||
}
|
||||
|
||||
if (differenceCount === 0) {
|
||||
const trialSignature = JSON.stringify(trialSetting);
|
||||
const currentSignature = resolutionSettingsSignature(this.settings);
|
||||
const assessment = assessTweakCompatibility(trialSetting, preferred);
|
||||
if (assessment.alignment === "matched") {
|
||||
this._log("The settings in the remote database are the same as the local database.", LOG_LEVEL_NOTICE);
|
||||
return { result: false, requireFetch: false };
|
||||
}
|
||||
const publication = await this.services.replicator.acquireActiveReplicatorContext();
|
||||
const settingsStillCurrent = () =>
|
||||
JSON.stringify(trialSetting) === trialSignature &&
|
||||
resolutionSettingsSignature(this.settings) === currentSignature;
|
||||
if (!settingsStillCurrent()) return { result: false, requireFetch: false };
|
||||
const stillCurrent = async () =>
|
||||
(await this.services.replicator.acquireActiveReplicatorContext()) === publication && settingsStillCurrent();
|
||||
const acceptedSettings = { ...trialSetting, ...settingsAfterAdoption(assessment, "adoptPreferred") };
|
||||
const autoAcceptSide = await this._shouldAutoAcceptCompatibleLossy(assessment);
|
||||
if (!(await stillCurrent())) return { result: false, requireFetch: false };
|
||||
if (autoAcceptSide === "REMOTE") return { result: acceptedSettings, requireFetch: false };
|
||||
if (autoAcceptSide === "CURRENT") return { result: false, requireFetch: false };
|
||||
|
||||
const impact = assessment.adoptPreferred.reconstruction;
|
||||
const additionalMessage =
|
||||
rebuildRequired && this.core.settings.isConfigured
|
||||
impact === "required" && this.settings.isConfigured
|
||||
? $msg("TweakMismatchResolve.Message.UseRemote.WarningRebuildRequired")
|
||||
: "";
|
||||
const additionalMessage2 =
|
||||
rebuildRecommended && this.core.settings.isConfigured
|
||||
impact === "recommended" && this.settings.isConfigured
|
||||
? $msg("TweakMismatchResolve.Message.UseRemote.WarningRebuildRecommended")
|
||||
: "";
|
||||
|
||||
const table = $msg("TweakMismatchResolve.Table", { rows: tableRows.join("\n") });
|
||||
|
||||
const message = $msg("TweakMismatchResolve.Message.Main", {
|
||||
table: table,
|
||||
additionalMessage: [additionalMessage, additionalMessage2].filter((v) => v).join("\n"),
|
||||
table: mismatchTable(assessment, "adoptPreferred"),
|
||||
additionalMessage: [additionalMessage, additionalMessage2].filter(Boolean).join("\n"),
|
||||
});
|
||||
|
||||
const CHOICE_USE_REMOTE = $msg("TweakMismatchResolve.Action.UseConfigured");
|
||||
const CHOICE_DISMISS = $msg("TweakMismatchResolve.Action.Dismiss");
|
||||
// const CHOICE_AND_VALUES = [
|
||||
// [CHOICE_USE_REMOTE, preferred],
|
||||
// [CHOICE_DISMISS, false]]
|
||||
const CHOICES = [CHOICE_USE_REMOTE, CHOICE_DISMISS];
|
||||
const retKey = await this.core.confirm.askSelectStringDialogue(message, CHOICES, {
|
||||
const useRemote = $msg("TweakMismatchResolve.Action.UseConfigured");
|
||||
const dismiss = $msg("TweakMismatchResolve.Action.Dismiss");
|
||||
const retKey = await this.core.confirm.askSelectStringDialogue(message, [useRemote, dismiss], {
|
||||
title: $msg("TweakMismatchResolve.Title.UseRemoteConfig"),
|
||||
timeout: 0,
|
||||
defaultAction: CHOICE_DISMISS,
|
||||
defaultAction: dismiss,
|
||||
});
|
||||
if (!retKey) return { result: false, requireFetch: false };
|
||||
if (retKey === CHOICE_DISMISS) return { result: false, requireFetch: false };
|
||||
if (retKey === CHOICE_USE_REMOTE) {
|
||||
return { result: { ...trialSetting, ...preferred }, requireFetch: rebuildRequired };
|
||||
}
|
||||
return { result: false, requireFetch: false };
|
||||
if (retKey !== useRemote || !(await stillCurrent())) return { result: false, requireFetch: false };
|
||||
return { result: acceptedSettings, requireFetch: impact === "required" };
|
||||
}
|
||||
|
||||
override onBindFunction(core: LiveSyncCore, services: InjectableServiceHub): void {
|
||||
|
||||
@@ -2,9 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
REMOTE_COUCHDB,
|
||||
TweakValuesTemplate,
|
||||
type RemoteDBSettings,
|
||||
type TweakValues,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { ModuleResolvingMismatchedTweaks } from "./ModuleResolveMismatchedTweaks";
|
||||
import { setLang } from "@/common/translation";
|
||||
import {
|
||||
@@ -14,10 +17,16 @@ import {
|
||||
type ReplicationAttemptFailure,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const BASE_TWEAKS = {
|
||||
...extractObject(TweakValuesTemplate, DEFAULT_SETTINGS),
|
||||
handleFilenameCaseSensitive: false,
|
||||
};
|
||||
|
||||
function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
const askSelectStringDialogue = vi.fn(async (..._args: unknown[]): Promise<string | undefined> => undefined);
|
||||
const applyPartial = vi.fn(async (_partial: Record<string, unknown>): Promise<void> => undefined);
|
||||
const reinitialise = vi.fn(async () => undefined);
|
||||
const publication = {};
|
||||
const core = {
|
||||
_services: {
|
||||
API: {
|
||||
@@ -31,6 +40,9 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
saveSettingData: vi.fn(async () => undefined),
|
||||
applyPartial,
|
||||
},
|
||||
replicator: {
|
||||
acquireActiveReplicatorContext: vi.fn(async () => publication),
|
||||
},
|
||||
},
|
||||
localDatabase: {
|
||||
managers: {
|
||||
@@ -39,6 +51,7 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
},
|
||||
settings: {
|
||||
...DEFAULT_SETTINGS,
|
||||
handleFilenameCaseSensitive: false,
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
...settingsOverride,
|
||||
},
|
||||
@@ -61,14 +74,170 @@ function createModule(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||
}
|
||||
|
||||
describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
it("compatibility: offers ordinary application for a missing legacy filename-case setting", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: false,
|
||||
customChunkSize: 60,
|
||||
usePluginSyncV2: true,
|
||||
handleFilenameCaseSensitive: false,
|
||||
});
|
||||
const preferred: TweakValues = {
|
||||
...DEFAULT_SETTINGS,
|
||||
customChunkSize: 0,
|
||||
usePluginSyncV2: false,
|
||||
};
|
||||
delete preferred.handleFilenameCaseSensitive;
|
||||
|
||||
await module._checkAndAskResolvingMismatchedTweaks(preferred);
|
||||
|
||||
expect(askSelectStringDialogue.mock.calls[0][1]).toContain("Apply settings to this device");
|
||||
expect(askSelectStringDialogue.mock.calls[0][0]).not.toContain("Handle files as Case-Sensitive");
|
||||
});
|
||||
|
||||
it("compares the trial configuration when deciding whether to accept compatible remote values", async () => {
|
||||
const { module, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: true,
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: 300,
|
||||
});
|
||||
const trial = {
|
||||
...DEFAULT_SETTINGS,
|
||||
hashAlg: "xxhash64",
|
||||
tweakModified: 100,
|
||||
} as RemoteDBSettings;
|
||||
const preferred = { ...trial, hashAlg: "xxhash32", tweakModified: 200 } as TweakValues;
|
||||
|
||||
const result = await module._askUseRemoteConfiguration(trial, preferred);
|
||||
|
||||
expect(result).toEqual({ result: { ...trial, ...preferred }, requireFetch: false });
|
||||
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards remote profile adoption if the active publication changed while awaiting it", async () => {
|
||||
const { module, core, askSelectStringDialogue } = createModule({
|
||||
autoAcceptCompatibleTweak: false,
|
||||
usePluginSyncV2: true,
|
||||
});
|
||||
let publication = {};
|
||||
core._services.replicator.acquireActiveReplicatorContext.mockImplementation(async () => publication);
|
||||
askSelectStringDialogue.mockImplementation(async () => {
|
||||
publication = {};
|
||||
return "Use configured settings";
|
||||
});
|
||||
const trial = { ...core.settings } as RemoteDBSettings;
|
||||
const preferred = { ...trial, usePluginSyncV2: false };
|
||||
|
||||
const result = await module._askUseRemoteConfiguration(trial, preferred);
|
||||
|
||||
expect(askSelectStringDialogue).toHaveBeenCalled();
|
||||
expect(result).toEqual({ result: false, requireFetch: false });
|
||||
});
|
||||
|
||||
it("discards a decision if the connection settings changed while awaiting it", async () => {
|
||||
const { module, core, reinitialise } = createModule({ hashAlg: "xxhash64" });
|
||||
const preferred = { ...DEFAULT_SETTINGS, hashAlg: "xxhash32" } as TweakValues;
|
||||
core._services.tweakValue = {
|
||||
checkAndAskResolvingMismatched: vi.fn(async () => {
|
||||
core.settings.couchDB_DBNAME = "another-database";
|
||||
return [preferred, false];
|
||||
}),
|
||||
};
|
||||
const updatePreferredRemote = vi.fn(async () => true);
|
||||
|
||||
const result = await module._askResolvingMismatchedTweaks(preferred, updatePreferredRemote);
|
||||
|
||||
expect(result).toBe("IGNORE");
|
||||
expect(core.settings.hashAlg).toBe("xxhash64");
|
||||
expect(core._services.setting.saveSettingData).not.toHaveBeenCalled();
|
||||
expect(reinitialise).not.toHaveBeenCalled();
|
||||
expect(updatePreferredRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a decision if its active publication was replaced while awaiting it", async () => {
|
||||
const { module, core, reinitialise } = createModule({ hashAlg: "xxhash64" });
|
||||
const preferred = { ...BASE_TWEAKS, hashAlg: "xxhash32" } as TweakValues;
|
||||
core._services.tweakValue = {
|
||||
checkAndAskResolvingMismatched: vi.fn(async () => [preferred, false]),
|
||||
};
|
||||
core._services.replicator.acquireActiveReplicatorContext.mockResolvedValueOnce({}).mockResolvedValueOnce({});
|
||||
const updatePreferredRemote = vi.fn(async () => true);
|
||||
|
||||
await expect(module._askResolvingMismatchedTweaks(preferred, updatePreferredRemote)).resolves.toBe("IGNORE");
|
||||
|
||||
expect(core._services.setting.saveSettingData).not.toHaveBeenCalled();
|
||||
expect(reinitialise).not.toHaveBeenCalled();
|
||||
expect(updatePreferredRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses each direction's assessed reconstruction consequence in the available choices", async () => {
|
||||
const { module, core, askSelectStringDialogue } = createModule({ autoAcceptCompatibleTweak: false });
|
||||
const preferred = { ...BASE_TWEAKS, encrypt: true };
|
||||
const assessment = assessTweakCompatibility(core.settings, preferred);
|
||||
const directionalAssessment = {
|
||||
...assessment,
|
||||
adoptCurrent: { ...assessment.adoptCurrent, reconstruction: "none" as const },
|
||||
};
|
||||
askSelectStringDialogue.mockResolvedValueOnce("Update remote database settings");
|
||||
|
||||
const result = await module._checkAndAskResolvingMismatchedTweaks(preferred, directionalAssessment);
|
||||
|
||||
expect(result).toEqual([true, false]);
|
||||
expect(askSelectStringDialogue.mock.calls[0][1]).toContain("Apply settings to this device, and fetch again");
|
||||
expect(askSelectStringDialogue.mock.calls[0][1]).not.toContain("Apply settings to this device");
|
||||
});
|
||||
|
||||
it("keeps explicitly chosen Fetch failures from becoming a successful retry", async () => {
|
||||
const { module, core } = createModule({ hashAlg: "xxhash64" });
|
||||
const preferred = { ...BASE_TWEAKS, hashAlg: "xxhash32" } as TweakValues;
|
||||
core._services.tweakValue = {
|
||||
checkAndAskResolvingMismatched: vi.fn(async () => [preferred, true]),
|
||||
};
|
||||
const failure = new Error("Fetch failed");
|
||||
core.rebuilder = {
|
||||
$fetchLocal: vi.fn(async () => {
|
||||
throw failure;
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(module._askResolvingMismatchedTweaks(preferred, async () => true)).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
it("does not erase an explicit local setting when accepting a partial remote configuration", async () => {
|
||||
const { module, core } = createModule({ handleFilenameCaseSensitive: false });
|
||||
core._services.tweakValue = {
|
||||
checkAndAskResolvingMismatched: vi.fn(async () => [{ customChunkSize: 30 }, false]),
|
||||
};
|
||||
|
||||
await expect(module._askResolvingMismatchedTweaks({ customChunkSize: 30 }, async () => true)).resolves.toBe(
|
||||
"CHECKAGAIN"
|
||||
);
|
||||
expect(core.settings.handleFilenameCaseSensitive).toBe(false);
|
||||
expect(core.settings.customChunkSize).toBe(30);
|
||||
});
|
||||
|
||||
it("preserves a remote recommendation which this device has not advertised", async () => {
|
||||
const { module, core } = createModule({ hashAlg: "xxhash64" });
|
||||
delete core.settings.readChunksOnline;
|
||||
const preferred = { ...BASE_TWEAKS, hashAlg: "xxhash32", readChunksOnline: false } as TweakValues;
|
||||
core._services.tweakValue = {
|
||||
checkAndAskResolvingMismatched: vi.fn(async () => [true, false]),
|
||||
};
|
||||
const updateRemote = vi.fn(async () => true);
|
||||
|
||||
await expect(module._askResolvingMismatchedTweaks(preferred, updateRemote)).resolves.toBe("CHECKAGAIN");
|
||||
expect(updateRemote).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ hashAlg: "xxhash64", readChunksOnline: false })
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the failed attempt hint and writes only through that exact active publication", async () => {
|
||||
const { module, core } = createModule();
|
||||
const attemptPreferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
...BASE_TWEAKS,
|
||||
customChunkSize: 60,
|
||||
};
|
||||
const replacementPreferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
...BASE_TWEAKS,
|
||||
customChunkSize: 99,
|
||||
};
|
||||
let updatePreferredRemote: ((setting: typeof core.settings) => Promise<boolean>) | undefined;
|
||||
@@ -118,7 +287,11 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
|
||||
await expect(module._anyAfterConnectCheckFailed(request)).resolves.toBe(true);
|
||||
|
||||
expect(askResolvingMismatched).toHaveBeenCalledWith(attemptPreferred, expect.any(Function));
|
||||
expect(askResolvingMismatched).toHaveBeenCalledWith(
|
||||
attemptPreferred,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ alignment: "mismatched" })
|
||||
);
|
||||
const effectiveSetting = { ...core.settings, customChunkSize: 64 };
|
||||
await expect(updatePreferredRemote?.(effectiveSetting)).resolves.toBe(true);
|
||||
expect(failedSetPreferred).toHaveBeenCalledWith(effectiveSetting);
|
||||
@@ -193,7 +366,7 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
const initialSettings = core.settings;
|
||||
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
...BASE_TWEAKS,
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: 200,
|
||||
} as Partial<TweakValues>;
|
||||
@@ -217,7 +390,7 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
});
|
||||
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
...BASE_TWEAKS,
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: 200,
|
||||
} as Partial<TweakValues>;
|
||||
@@ -239,7 +412,7 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
tweakModified: currentModified,
|
||||
});
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
...BASE_TWEAKS,
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: preferredModified,
|
||||
} as Partial<TweakValues>;
|
||||
@@ -260,7 +433,7 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
});
|
||||
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
...BASE_TWEAKS,
|
||||
hashAlg: "xxhash32",
|
||||
encrypt: true,
|
||||
tweakModified: 200,
|
||||
@@ -281,7 +454,7 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
askSelectStringDialogue.mockResolvedValueOnce("Apply settings to this device, and fetch again");
|
||||
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
...BASE_TWEAKS,
|
||||
hashAlg: "xxhash32",
|
||||
} as TweakValues;
|
||||
|
||||
@@ -325,7 +498,7 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
});
|
||||
const initialSettings = core.settings;
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
...BASE_TWEAKS,
|
||||
hashAlg: "xxhash32",
|
||||
tweakModified: 200,
|
||||
} as TweakValues;
|
||||
@@ -372,7 +545,7 @@ describe("ModuleResolvingMismatchedTweaks setting labels", () => {
|
||||
tweakModified: 100,
|
||||
});
|
||||
const preferred = {
|
||||
...(DEFAULT_SETTINGS as unknown as TweakValues),
|
||||
...BASE_TWEAKS,
|
||||
hashAlg: "xxhash32",
|
||||
encrypt: true,
|
||||
tweakModified: 200,
|
||||
|
||||
@@ -7,13 +7,9 @@ import {
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const";
|
||||
import FetchEverything from "@/modules/features/SetupWizard/dialogs/FetchEverything.svelte";
|
||||
import RebuildEverything from "@/modules/features/SetupWizard/dialogs/RebuildEverything.svelte";
|
||||
import { extractObject } from "octagonal-wheels/object";
|
||||
import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import {
|
||||
RemotePreferredTweakStatuses,
|
||||
TweakValuesShouldMatchedTemplate,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/tweak.definition";
|
||||
import { assessTweakCompatibility, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { RemotePreferredTweakStatuses } from "@vrtmrz/livesync-commonlib/compat/common/models/tweak.definition";
|
||||
import type {
|
||||
FetchEverythingResult,
|
||||
RebuildEverythingResult,
|
||||
@@ -301,12 +297,8 @@ export async function adjustSettingToRemote(
|
||||
}
|
||||
|
||||
const remoteTweaks = remoteResult.values;
|
||||
const necessary = extractObject(TweakValuesShouldMatchedTemplate, remoteTweaks);
|
||||
// Check if any necessary tweak value is different from current config.
|
||||
const differentItems = Object.entries(necessary).filter(([key, value]) => {
|
||||
return config[key as keyof ObsidianLiveSyncSettings] !== value;
|
||||
});
|
||||
if (differentItems.length === 0) {
|
||||
const assessment = assessTweakCompatibility(config, remoteTweaks);
|
||||
if (assessment.alignment === "matched") {
|
||||
log("Remote configuration matches local configuration. No changes applied.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
await host.services.UI.confirm.askSelectStringDialogue(
|
||||
@@ -321,7 +313,7 @@ export async function adjustSettingToRemote(
|
||||
|
||||
config = {
|
||||
...config,
|
||||
...(Object.fromEntries(differentItems) as Partial<ObsidianLiveSyncSettings>),
|
||||
...assessment.adoptPreferred.changes,
|
||||
} satisfies ObsidianLiveSyncSettings;
|
||||
await host.services.setting.applyExternalSettings(config, true);
|
||||
log("Remote configuration applied.", LOG_LEVEL_NOTICE);
|
||||
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
flagHandlerToEventHandler,
|
||||
} from "./redFlag";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
TweakValuesRecommendedTemplate,
|
||||
TweakValuesShouldMatchedTemplate,
|
||||
TweakValuesTemplate,
|
||||
type TweakValues,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
ExtraOnLocal,
|
||||
@@ -1149,6 +1151,26 @@ describe("Red Flag Feature", () => {
|
||||
});
|
||||
|
||||
describe("Remote configuration adjustment", () => {
|
||||
it("compatibility: preserves the local filename-case value when the remote omits it", async () => {
|
||||
const host = createHostMock();
|
||||
const config = {
|
||||
...DEFAULT_SETTINGS,
|
||||
...TweakValuesShouldMatchedTemplate,
|
||||
handleFilenameCaseSensitive: false,
|
||||
};
|
||||
const remote: TweakValues = { ...TweakValuesShouldMatchedTemplate };
|
||||
delete remote.handleFilenameCaseSensitive;
|
||||
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(availableRemoteTweaks(remote));
|
||||
|
||||
await adjustSettingToRemote(host as any, createLoggerMock(), config);
|
||||
|
||||
expect(host.mocks.ui.confirm.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
expect(host.mocks.setting.applyExternalSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ handleFilenameCaseSensitive: false }),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps this device's E2EE settings when preparing to overwrite the remote", async () => {
|
||||
const host = createHostMock();
|
||||
Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
||||
import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
import { balanceChunkPurgedDBs, purgeUnreferencedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/chunks";
|
||||
@@ -15,7 +16,7 @@ import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
|
||||
type CentralCompatibilityRecoveryServices = Pick<
|
||||
LiveSyncBaseCore["services"],
|
||||
"API" | "appLifecycle" | "replicator" | "tweakValue"
|
||||
"API" | "appLifecycle" | "replicator" | "setting" | "tweakValue"
|
||||
>;
|
||||
|
||||
/** Collaborators for applying a compatibility decision to its failed publication. */
|
||||
@@ -145,6 +146,15 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH &&
|
||||
recovery.preferredTweakValue
|
||||
) {
|
||||
const isCurrent = await context.services.replicator.runWithActiveReplicatorContext(
|
||||
(activeContext) => activeContext === failedContext
|
||||
);
|
||||
// Compare in memory only: these snapshots can contain connection credentials.
|
||||
if (!isCurrent || JSON.stringify(setting) !== JSON.stringify(context.services.setting.currentSettings())) {
|
||||
return false;
|
||||
}
|
||||
const assessment =
|
||||
recovery.tweakAssessment ?? assessTweakCompatibility(setting, recovery.preferredTweakValue);
|
||||
await context.services.tweakValue.askResolvingMismatched(
|
||||
recovery.preferredTweakValue,
|
||||
async (effectiveSetting) => {
|
||||
@@ -156,7 +166,8 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
updated = true;
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
},
|
||||
assessment
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
|
||||
import { defaultLogger, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
@@ -23,6 +24,72 @@ import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/rep
|
||||
import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery";
|
||||
|
||||
describe("central compatibility recovery", () => {
|
||||
it("passes the failed attempt's exact tweak assessment to mismatch resolution", async () => {
|
||||
const setting = { customChunkSize: 0 };
|
||||
const preferredTweakValue = { customChunkSize: 60 };
|
||||
const tweakAssessment = assessTweakCompatibility(setting, preferredTweakValue);
|
||||
const failedContext = { provider: {}, replicator: {} };
|
||||
const askResolvingMismatched = vi.fn(async (..._args: unknown[]) => "CHECKAGAIN");
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
services: {
|
||||
setting: { currentSettings: () => setting },
|
||||
replicator: {
|
||||
runWithActiveReplicatorContext: async (task: (context: unknown) => unknown) => task(failedContext),
|
||||
},
|
||||
tweakValue: { askResolvingMismatched },
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await recovery.handleReplicationFailure({
|
||||
context: failedContext,
|
||||
setting,
|
||||
outcome: replicationFailed(new Error("mismatched"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
|
||||
preferredTweakValue,
|
||||
tweakAssessment,
|
||||
}),
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} as never);
|
||||
|
||||
expect(askResolvingMismatched.mock.calls[0][2]).toBe(tweakAssessment);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["settings", "publication"])(
|
||||
"discards a mismatch after its %s changed before recovery",
|
||||
async (changed) => {
|
||||
const setting = { customChunkSize: 0, couchDB_DBNAME: "original" };
|
||||
const failedContext = { provider: {}, replicator: {} };
|
||||
const currentContext = changed === "publication" ? { provider: {}, replicator: {} } : failedContext;
|
||||
const currentSetting = changed === "settings" ? { ...setting, couchDB_DBNAME: "replacement" } : setting;
|
||||
const askResolvingMismatched = vi.fn(async () => "CHECKAGAIN");
|
||||
const recovery = createCentralCompatibilityRecovery({
|
||||
services: {
|
||||
setting: { currentSettings: () => currentSetting },
|
||||
replicator: {
|
||||
runWithActiveReplicatorContext: async (task: (context: unknown) => unknown) =>
|
||||
task(currentContext),
|
||||
},
|
||||
tweakValue: { askResolvingMismatched },
|
||||
},
|
||||
} as never);
|
||||
|
||||
await recovery.handleReplicationFailure({
|
||||
context: failedContext,
|
||||
setting,
|
||||
outcome: replicationFailed(new Error("mismatched"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
|
||||
preferredTweakValue: { customChunkSize: 60 },
|
||||
}),
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} as never);
|
||||
|
||||
expect(askResolvingMismatched).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it("characterises unattended central failure handling as one INFO log without a NOTICE", async () => {
|
||||
const log = vi.fn((_message: unknown, _level?: number, _key?: string) => undefined);
|
||||
setGlobalLogFunction(log);
|
||||
@@ -69,6 +136,7 @@ describe("central compatibility recovery", () => {
|
||||
};
|
||||
const failedContext = { provider: {}, replicator: failedReplicator };
|
||||
const replacementContext = { provider: {}, replicator: replacementReplicator };
|
||||
let activeContext = failedContext;
|
||||
const preferredTweakValue = { customChunkSize: 60 };
|
||||
const outcome = replicationFailed(new Error("mismatched"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
|
||||
@@ -81,9 +149,10 @@ describe("central compatibility recovery", () => {
|
||||
services: {
|
||||
appLifecycle: {},
|
||||
API: {},
|
||||
setting: { currentSettings: () => ({}) },
|
||||
replicator: {
|
||||
runWithActiveReplicatorContext: vi.fn(async (task: (context: unknown) => unknown) =>
|
||||
task(replacementContext)
|
||||
task(activeContext)
|
||||
),
|
||||
},
|
||||
tweakValue: { askResolvingMismatched },
|
||||
@@ -118,10 +187,15 @@ describe("central compatibility recovery", () => {
|
||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
} as never);
|
||||
expect(askResolvingMismatched).toHaveBeenCalledWith(preferredTweakValue, expect.any(Function));
|
||||
expect(askResolvingMismatched).toHaveBeenCalledWith(
|
||||
preferredTweakValue,
|
||||
expect.any(Function),
|
||||
assessTweakCompatibility({}, preferredTweakValue)
|
||||
);
|
||||
const updatePreferredRemote = askResolvingMismatched.mock.calls[0][1] as (
|
||||
setting: Record<string, unknown>
|
||||
) => Promise<boolean>;
|
||||
activeContext = replacementContext;
|
||||
await expect(updatePreferredRemote({ customChunkSize: 64 })).resolves.toBe(false);
|
||||
expect(failedSetPreferred).not.toHaveBeenCalled();
|
||||
expect(replacementSetPreferred).not.toHaveBeenCalled();
|
||||
@@ -143,6 +217,7 @@ describe("central compatibility recovery", () => {
|
||||
services: {
|
||||
appLifecycle: {},
|
||||
API: {},
|
||||
setting: { currentSettings: () => ({}) },
|
||||
replicator: {
|
||||
runWithActiveReplicatorContext: vi.fn(async (task: (context: unknown) => unknown) =>
|
||||
task(failedContext)
|
||||
|
||||
@@ -86,6 +86,7 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
|
||||
API: services.API,
|
||||
appLifecycle: services.appLifecycle,
|
||||
replicator: services.replicator,
|
||||
setting: services.setting,
|
||||
tweakValue: services.tweakValue,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -156,6 +156,8 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) --
|
||||
npm run test:e2e:obsidian:cli-to-obsidian-sync
|
||||
```
|
||||
|
||||
`test:e2e:obsidian:tweak-compatibility` exercises the mismatch dialogue against temporary CouchDB databases. Build the plug-in first. The scenario removes the legacy filename-case value from the remote preferred settings, applies compatible differences through the ordinary settings action, and verifies note synchronisation and a subsequent restart. Separate true/false and true/missing filename-case mismatches check that Fetch remains required. The true/missing case also fetches the files and verifies a subsequent synchronisation attempt. A final case changes the remote while the dialogue is open, selects the stale Fetch action, and checks that the current settings, existing local file, and documents in both remotes remain unchanged.
|
||||
|
||||
`test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service and starts with isolated Object Storage settings and the device-local compatibility acknowledgement already in place, keeping the scenario focused on upload rather than unconfigured start-up or setup. It confirms those settings through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance.
|
||||
|
||||
`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique MinIO prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies the A-to-B note through explicit replication, then verifies that the B-to-A note arrives through `syncOnStart` after restarting the first device, without requesting manual replication. It captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped.
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Verifies the central CouchDB tweak compatibility boundary in real Obsidian.
|
||||
*
|
||||
* The source Vault creates a remote preferred profile with a missing legacy
|
||||
* filename-case value. The target has the effective false value, but differs
|
||||
* in the chunk size and V2 customisation setting. Applying the ordinary
|
||||
* remote settings action must permit a fresh synchronisation without a Fetch. A
|
||||
* Separate true/false and true/missing case controls keep Fetch required.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { MILESTONE_DOCID } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { evalObsidianJson } from "../runner/cli.ts";
|
||||
import {
|
||||
assertCouchDbReachable,
|
||||
createCouchDbDatabase,
|
||||
deleteCouchDbDatabase,
|
||||
fetchAllCouchDbDocs,
|
||||
fetchCouchDbDocument,
|
||||
fetchCouchDbLocalDocs,
|
||||
loadCouchDbConfig,
|
||||
makeUniqueDatabaseName,
|
||||
putCouchDbDocument,
|
||||
waitForCouchDbDocs,
|
||||
type CouchDbConfig,
|
||||
type CouchDbDocument,
|
||||
} from "../runner/couchdb.ts";
|
||||
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
|
||||
import {
|
||||
assertE2eCompatibilityMarker,
|
||||
assertEqual,
|
||||
configureCouchDb,
|
||||
createE2eCouchDbPluginData,
|
||||
createE2eObsidianDeviceLocalState,
|
||||
prepareRemote,
|
||||
waitForLiveSyncCoreReady,
|
||||
waitForLocalDatabaseEntry,
|
||||
} from "../runner/liveSyncWorkflow.ts";
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { createTemporaryVault, type TemporaryVault } from "../runner/vault.ts";
|
||||
import { waitForVisibleObsidianDialogue, withObsidianPage } from "../runner/ui.ts";
|
||||
|
||||
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000";
|
||||
process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "20000";
|
||||
|
||||
const milestoneId = MILESTONE_DOCID;
|
||||
const compatibilityTitle = "Configuration Mismatch Detected";
|
||||
const applySettingsAction = "Apply settings to this device";
|
||||
const applySettingsWithFetchAction = "Apply settings to this device, and fetch again";
|
||||
const dismissAction = "Dismiss";
|
||||
const sourceNotePath = "E2E/tweak-compatibility/source.md";
|
||||
const restartedNotePath = "E2E/tweak-compatibility/restarted.md";
|
||||
const sourceNoteContent = `# Tweak compatibility source\n\n${"source-content ".repeat(1200)}\n`;
|
||||
const restartedNoteContent = `# Tweak compatibility restart\n\n${"restart-content ".repeat(1200)}\n`;
|
||||
|
||||
type MilestoneDocument = CouchDbDocument & {
|
||||
tweak_values?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ReplicationResult = {
|
||||
succeeded: boolean;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function couchDbSettings(couchDb: CouchDbConfig, dbName: string) {
|
||||
return {
|
||||
uri: couchDb.uri,
|
||||
username: couchDb.username,
|
||||
password: couchDb.password,
|
||||
dbName,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeNote(cliBinary: string, env: NodeJS.ProcessEnv, path: string, content: string): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
`const content=${JSON.stringify(content)};`,
|
||||
"const folder=path.split('/').slice(0,-1).join('/');",
|
||||
"if(folder&&!(await app.vault.adapter.exists(folder))) await app.vault.createFolder(folder);",
|
||||
"const existing=app.vault.getAbstractFileByPath(path);",
|
||||
"if(existing) await app.vault.delete(existing);",
|
||||
"await app.vault.create(path,content);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForPathContent(vaultPath: string, path: string, expected: string): Promise<void> {
|
||||
const fullPath = join(vaultPath, path);
|
||||
const deadline = Date.now() + Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ?? 10000);
|
||||
let lastContent = "";
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
lastContent = await readFile(fullPath, "utf-8");
|
||||
if (lastContent === expected) return;
|
||||
} catch {
|
||||
// The file may not have been reflected yet.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(`Timed out waiting for reflected file: ${fullPath}\nLast content:\n${lastContent}`);
|
||||
}
|
||||
|
||||
async function replicateOnce(cliBinary: string, env: NodeJS.ProcessEnv): Promise<ReplicationResult> {
|
||||
return await evalObsidianJson<ReplicationResult>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"await core.services.fileProcessing.commitPendingFileEvents();",
|
||||
"const result=await core.services.replication.replicate(true);",
|
||||
"return JSON.stringify({succeeded:result===true,raw:result??null});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function selectCompatibilityAction(
|
||||
port: number,
|
||||
action: string,
|
||||
forbiddenAction?: string,
|
||||
requiredAction?: string
|
||||
): Promise<void> {
|
||||
const timeoutMs = Number(process.env.E2E_OBSIDIAN_UI_TIMEOUT_MS ?? 15000);
|
||||
await withObsidianPage(port, async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, compatibilityTitle, timeoutMs);
|
||||
const selected = dialogue.getByRole("button", { name: action, exact: true });
|
||||
await selected.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
if (forbiddenAction !== undefined) {
|
||||
const forbidden = dialogue.getByRole("button", { name: forbiddenAction, exact: true });
|
||||
assertEqual(
|
||||
await forbidden.count(),
|
||||
0,
|
||||
`The compatibility dialogue unexpectedly offered '${forbiddenAction}'.`
|
||||
);
|
||||
}
|
||||
if (requiredAction !== undefined) {
|
||||
await dialogue
|
||||
.getByRole("button", { name: requiredAction, exact: true })
|
||||
.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
}
|
||||
await selected.click({ timeout: timeoutMs });
|
||||
await dialogue.waitFor({ state: "hidden", timeout: timeoutMs });
|
||||
});
|
||||
}
|
||||
|
||||
async function removeLegacyCasePreference(couchDb: CouchDbConfig, dbName: string): Promise<void> {
|
||||
const milestone = (await fetchCouchDbDocument(couchDb, dbName, milestoneId)) as MilestoneDocument;
|
||||
const tweakValues = milestone.tweak_values;
|
||||
assert(isRecord(tweakValues), "The remote milestone has no tweak-values map.");
|
||||
const preferred = tweakValues.PREFERRED;
|
||||
assert(isRecord(preferred), "The remote milestone has no preferred tweak profile.");
|
||||
assertEqual(preferred.customChunkSize, 0, "The source remote profile did not persist customChunkSize=0.");
|
||||
assertEqual(preferred.usePluginSyncV2, false, "The source remote profile did not persist usePluginSyncV2=false.");
|
||||
delete preferred.handleFilenameCaseSensitive;
|
||||
await putCouchDbDocument(couchDb, dbName, milestone);
|
||||
|
||||
const rewritten = (await fetchCouchDbDocument(couchDb, dbName, milestoneId)) as MilestoneDocument;
|
||||
const rewrittenTweaks = rewritten.tweak_values;
|
||||
assert(isRecord(rewrittenTweaks), "The rewritten remote milestone has no tweak-values map.");
|
||||
const rewrittenPreferred = rewrittenTweaks.PREFERRED;
|
||||
assert(isRecord(rewrittenPreferred), "The rewritten remote milestone has no preferred tweak profile.");
|
||||
assertEqual(
|
||||
Object.prototype.hasOwnProperty.call(rewrittenPreferred, "handleFilenameCaseSensitive"),
|
||||
false,
|
||||
"The remote preferred profile still advertised the legacy filename-case value."
|
||||
);
|
||||
}
|
||||
|
||||
async function startSession(
|
||||
binary: string,
|
||||
cliBinary: string,
|
||||
vault: TemporaryVault,
|
||||
pluginData?: Record<string, unknown>
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
return await startObsidianLiveSyncSession({
|
||||
binary,
|
||||
cliBinary,
|
||||
vault,
|
||||
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
|
||||
...(pluginData === undefined ? {} : { pluginData }),
|
||||
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareConfiguredSession(
|
||||
binary: string,
|
||||
cliBinary: string,
|
||||
vault: TemporaryVault,
|
||||
settings: ReturnType<typeof couchDbSettings>,
|
||||
overrides: Record<string, unknown>
|
||||
): Promise<ObsidianLiveSyncSession> {
|
||||
const session = await startSession(binary, cliBinary, vault, createE2eCouchDbPluginData(settings, overrides));
|
||||
try {
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await assertE2eCompatibilityMarker(cliBinary, session.cliEnv);
|
||||
await configureCouchDb(cliBinary, session.cliEnv, settings, overrides);
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
return session;
|
||||
} catch (error) {
|
||||
await session.app.stop().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readTweakState(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv
|
||||
): Promise<{ customChunkSize: unknown; usePluginSyncV2: unknown; handleFilenameCaseSensitive: unknown }> {
|
||||
return await evalObsidianJson<{
|
||||
customChunkSize: unknown;
|
||||
usePluginSyncV2: unknown;
|
||||
handleFilenameCaseSensitive: unknown;
|
||||
}>(
|
||||
cliBinary,
|
||||
[
|
||||
"(()=>{",
|
||||
"const settings=app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();",
|
||||
"return JSON.stringify({customChunkSize:settings.customChunkSize,usePluginSyncV2:settings.usePluginSyncV2,handleFilenameCaseSensitive:settings.handleFilenameCaseSensitive});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function remoteDocumentSnapshot(couchDb: CouchDbConfig, dbName: string): Promise<string> {
|
||||
const [documents, localDocuments] = await Promise.all([
|
||||
fetchAllCouchDbDocs(couchDb, dbName),
|
||||
fetchCouchDbLocalDocs(couchDb, dbName),
|
||||
]);
|
||||
return JSON.stringify([documents.rows, localDocuments.rows]);
|
||||
}
|
||||
|
||||
async function verifyStaleTargetChoice(
|
||||
cliBinary: string,
|
||||
session: ObsidianLiveSyncSession,
|
||||
couchDb: CouchDbConfig,
|
||||
originalDbName: string,
|
||||
replacementDbName: string
|
||||
): Promise<void> {
|
||||
const overrides = {
|
||||
customChunkSize: 60,
|
||||
usePluginSyncV2: true,
|
||||
handleFilenameCaseSensitive: true,
|
||||
autoAcceptCompatibleTweak: false,
|
||||
};
|
||||
await configureCouchDb(cliBinary, session.cliEnv, couchDbSettings(couchDb, originalDbName), overrides);
|
||||
await prepareRemote(cliBinary, session.cliEnv);
|
||||
const replicationPromise = replicateOnce(cliBinary, session.cliEnv);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const dialogue = await waitForVisibleObsidianDialogue(page, compatibilityTitle, 15000);
|
||||
await dialogue.getByRole("button", { name: applySettingsWithFetchAction, exact: true }).waitFor();
|
||||
});
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
"(async()=>{globalThis.__tweakCompatibilityPublication=await app.plugins.plugins['obsidian-livesync'].core.services.replicator.acquireActiveReplicatorContext();return JSON.stringify(true);})()",
|
||||
session.cliEnv
|
||||
);
|
||||
await configureCouchDb(cliBinary, session.cliEnv, couchDbSettings(couchDb, replacementDbName), overrides);
|
||||
const publicationChanged = await evalObsidianJson<boolean>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
"const services=app.plugins.plugins['obsidian-livesync'].core.services;",
|
||||
"const current=await services.replicator.acquireActiveReplicatorContext();",
|
||||
"globalThis.__tweakCompatibilitySettings=JSON.stringify(services.setting.currentSettings());",
|
||||
"return JSON.stringify(current!==globalThis.__tweakCompatibilityPublication);",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(publicationChanged, true, "Changing the remote did not replace the active publication.");
|
||||
const originalBefore = await remoteDocumentSnapshot(couchDb, originalDbName);
|
||||
const replacementBefore = await remoteDocumentSnapshot(couchDb, replacementDbName);
|
||||
await selectCompatibilityAction(session.remoteDebuggingPort, applySettingsWithFetchAction);
|
||||
const result = await replicationPromise;
|
||||
assertEqual(result.succeeded, false, "The stale remote choice incorrectly completed the original replication.");
|
||||
const settingsUnchanged = await evalObsidianJson<boolean>(
|
||||
cliBinary,
|
||||
[
|
||||
"(()=>{",
|
||||
"const settings=app.plugins.plugins['obsidian-livesync'].core.services.setting.currentSettings();",
|
||||
"const unchanged=JSON.stringify(settings)===globalThis.__tweakCompatibilitySettings;",
|
||||
"delete globalThis.__tweakCompatibilitySettings;delete globalThis.__tweakCompatibilityPublication;",
|
||||
"return JSON.stringify(unchanged);",
|
||||
"})()",
|
||||
].join(""),
|
||||
session.cliEnv
|
||||
);
|
||||
assertEqual(settingsUnchanged, true, "The stale choice adopted settings from the previous remote.");
|
||||
assertEqual(
|
||||
(await remoteDocumentSnapshot(couchDb, originalDbName)) === originalBefore,
|
||||
true,
|
||||
"The stale choice changed documents in the previous remote."
|
||||
);
|
||||
assertEqual(
|
||||
(await remoteDocumentSnapshot(couchDb, replacementDbName)) === replacementBefore,
|
||||
true,
|
||||
"The stale choice changed documents in the replacement remote."
|
||||
);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const binary = requireObsidianBinary();
|
||||
const cli = discoverObsidianCli();
|
||||
if (!cli.binary) {
|
||||
throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
|
||||
}
|
||||
|
||||
const couchDb = await loadCouchDbConfig();
|
||||
const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "tweak-compatibility");
|
||||
const replacementDbName = makeUniqueDatabaseName(couchDb.dbPrefix, "tweak-replacement");
|
||||
const settings = couchDbSettings(couchDb, dbName);
|
||||
const sourceVault = await createTemporaryVault();
|
||||
const targetVault = await createTemporaryVault();
|
||||
const controlVault = await createTemporaryVault();
|
||||
let sourceSession: ObsidianLiveSyncSession | undefined;
|
||||
let targetSession: ObsidianLiveSyncSession | undefined;
|
||||
let controlSession: ObsidianLiveSyncSession | undefined;
|
||||
|
||||
try {
|
||||
await assertCouchDbReachable(couchDb);
|
||||
await createCouchDbDatabase(couchDb, dbName);
|
||||
await createCouchDbDatabase(couchDb, replacementDbName);
|
||||
console.log(`Using Obsidian executable: ${binary}`);
|
||||
console.log(`Temporary CouchDB database: ${dbName}`);
|
||||
|
||||
sourceSession = await prepareConfiguredSession(binary, cli.binary, sourceVault, settings, {
|
||||
customChunkSize: 0,
|
||||
usePluginSyncV2: false,
|
||||
handleFilenameCaseSensitive: false,
|
||||
autoAcceptCompatibleTweak: false,
|
||||
});
|
||||
await prepareRemote(cli.binary, sourceSession.cliEnv);
|
||||
await writeNote(cli.binary, sourceSession.cliEnv, sourceNotePath, sourceNoteContent);
|
||||
const sourceEntry = await waitForLocalDatabaseEntry(cli.binary, sourceSession.cliEnv, sourceNotePath);
|
||||
const sourceReplication = await replicateOnce(cli.binary, sourceSession.cliEnv);
|
||||
assertEqual(sourceReplication.succeeded, true, "The source Vault could not seed the CouchDB remote.");
|
||||
await waitForCouchDbDocs(couchDb, dbName, (docs) => {
|
||||
const ids = new Set(docs.map((doc) => doc._id));
|
||||
return ids.has(sourceEntry.id) && sourceEntry.children.every((child) => ids.has(child));
|
||||
});
|
||||
await sourceSession.app.stop();
|
||||
sourceSession = undefined;
|
||||
|
||||
await removeLegacyCasePreference(couchDb, dbName);
|
||||
|
||||
targetSession = await prepareConfiguredSession(binary, cli.binary, targetVault, settings, {
|
||||
customChunkSize: 60,
|
||||
usePluginSyncV2: true,
|
||||
handleFilenameCaseSensitive: false,
|
||||
autoAcceptCompatibleTweak: false,
|
||||
});
|
||||
await prepareRemote(cli.binary, targetSession.cliEnv);
|
||||
const targetReplicationPromise = replicateOnce(cli.binary, targetSession.cliEnv);
|
||||
await selectCompatibilityAction(targetSession.remoteDebuggingPort, applySettingsAction);
|
||||
const targetReplication = await targetReplicationPromise;
|
||||
assertEqual(
|
||||
targetReplication.succeeded,
|
||||
false,
|
||||
"The original failed attempt was incorrectly reported as completed after setting adoption."
|
||||
);
|
||||
const afterAdoption = await replicateOnce(cli.binary, targetSession.cliEnv);
|
||||
assertEqual(afterAdoption.succeeded, true, "A fresh synchronisation after ordinary setting adoption failed.");
|
||||
await waitForPathContent(targetVault.path, sourceNotePath, sourceNoteContent);
|
||||
|
||||
await targetSession.app.stop();
|
||||
targetSession = await startSession(binary, cli.binary, targetVault);
|
||||
await waitForLiveSyncCoreReady(cli.binary, targetSession.cliEnv);
|
||||
await assertE2eCompatibilityMarker(cli.binary, targetSession.cliEnv);
|
||||
const restartedState = await readTweakState(cli.binary, targetSession.cliEnv);
|
||||
assertEqual(
|
||||
restartedState.customChunkSize,
|
||||
0,
|
||||
"The applied remote custom chunk size was not retained after restart."
|
||||
);
|
||||
assertEqual(
|
||||
restartedState.usePluginSyncV2,
|
||||
false,
|
||||
"The applied remote V2 setting was not retained after restart."
|
||||
);
|
||||
assertEqual(
|
||||
restartedState.handleFilenameCaseSensitive,
|
||||
false,
|
||||
"The effective false filename-case setting was not retained after restart."
|
||||
);
|
||||
await writeNote(cli.binary, targetSession.cliEnv, restartedNotePath, restartedNoteContent);
|
||||
const restartedEntry = await waitForLocalDatabaseEntry(cli.binary, targetSession.cliEnv, restartedNotePath);
|
||||
const restartedReplication = await replicateOnce(cli.binary, targetSession.cliEnv);
|
||||
assertEqual(restartedReplication.succeeded, true, "Synchronisation did not remain compatible after restart.");
|
||||
await waitForCouchDbDocs(couchDb, dbName, (docs) => {
|
||||
const ids = new Set(docs.map((doc) => doc._id));
|
||||
return ids.has(restartedEntry.id) && restartedEntry.children.every((child) => ids.has(child));
|
||||
});
|
||||
await targetSession.app.stop();
|
||||
targetSession = undefined;
|
||||
|
||||
controlSession = await prepareConfiguredSession(binary, cli.binary, controlVault, settings, {
|
||||
customChunkSize: 0,
|
||||
usePluginSyncV2: false,
|
||||
handleFilenameCaseSensitive: true,
|
||||
autoAcceptCompatibleTweak: false,
|
||||
});
|
||||
await prepareRemote(cli.binary, controlSession.cliEnv);
|
||||
const controlReplicationPromise = replicateOnce(cli.binary, controlSession.cliEnv);
|
||||
await selectCompatibilityAction(
|
||||
controlSession.remoteDebuggingPort,
|
||||
dismissAction,
|
||||
applySettingsAction,
|
||||
applySettingsWithFetchAction
|
||||
);
|
||||
const controlReplication = await controlReplicationPromise;
|
||||
assertEqual(
|
||||
controlReplication.succeeded,
|
||||
false,
|
||||
"The control mismatch unexpectedly synchronised without a Fetch."
|
||||
);
|
||||
await removeLegacyCasePreference(couchDb, dbName);
|
||||
const legacyControlPromise = replicateOnce(cli.binary, controlSession.cliEnv);
|
||||
await selectCompatibilityAction(
|
||||
controlSession.remoteDebuggingPort,
|
||||
dismissAction,
|
||||
applySettingsAction,
|
||||
applySettingsWithFetchAction
|
||||
);
|
||||
const legacyControl = await legacyControlPromise;
|
||||
assertEqual(legacyControl.succeeded, false, "The true/missing mismatch unexpectedly synchronised.");
|
||||
const rejectedState = await readTweakState(cli.binary, controlSession.cliEnv);
|
||||
assertEqual(
|
||||
rejectedState.handleFilenameCaseSensitive,
|
||||
true,
|
||||
"Dismissing the mismatch changed the case setting."
|
||||
);
|
||||
const fileReflected = await evalObsidianJson<boolean>(
|
||||
cli.binary,
|
||||
`(async()=>JSON.stringify(await app.vault.adapter.exists(${JSON.stringify(sourceNotePath)})))()`,
|
||||
controlSession.cliEnv
|
||||
);
|
||||
assertEqual(fileReflected, false, "The rejected mismatch reflected a remote file.");
|
||||
const fetchPromise = replicateOnce(cli.binary, controlSession.cliEnv);
|
||||
await selectCompatibilityAction(controlSession.remoteDebuggingPort, applySettingsWithFetchAction);
|
||||
// Fetch can replace the active publication, so the original rejected attempt
|
||||
// need not retry. A separate attempt must use the rebuilt local database.
|
||||
await fetchPromise;
|
||||
await waitForLiveSyncCoreReady(cli.binary, controlSession.cliEnv);
|
||||
await waitForPathContent(controlVault.path, sourceNotePath, sourceNoteContent);
|
||||
const fetchedState = await readTweakState(cli.binary, controlSession.cliEnv);
|
||||
assertEqual(fetchedState.handleFilenameCaseSensitive, false, "Fetch did not adopt the remote case setting.");
|
||||
const afterFetch = await replicateOnce(cli.binary, controlSession.cliEnv);
|
||||
assertEqual(afterFetch.succeeded, true, "A fresh attempt after Fetch did not synchronise.");
|
||||
console.log("Ordinary apply, restart continuity, true/false rejection, and true/missing Fetch passed.");
|
||||
await verifyStaleTargetChoice(cli.binary, controlSession, couchDb, dbName, replacementDbName);
|
||||
await waitForPathContent(controlVault.path, sourceNotePath, sourceNoteContent);
|
||||
await controlSession.app.stop();
|
||||
controlSession = undefined;
|
||||
|
||||
console.log("Tweak compatibility also rejected a stale Fetch choice after the remote changed.");
|
||||
} finally {
|
||||
if (sourceSession) await sourceSession.app.stop().catch(() => undefined);
|
||||
if (targetSession) await targetSession.app.stop().catch(() => undefined);
|
||||
if (controlSession) await controlSession.app.stop().catch(() => undefined);
|
||||
await Promise.all([sourceVault.dispose(), targetVault.dispose(), controlVault.dispose()]);
|
||||
if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") {
|
||||
for (const database of [dbName, replacementDbName]) {
|
||||
await deleteCouchDbDatabase(couchDb, database).catch((error: unknown) => {
|
||||
console.warn(error instanceof Error ? error.message : error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error instanceof Error ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -12,6 +12,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- A missing legacy file-name case setting no longer makes the configuration mismatch dialogue require a database rebuild when this device already uses case-insensitive handling. Case-sensitive handling now correctly requires a compatibility decision when the remote omits that setting.
|
||||
- Configuration review now compares the selected remote profile's trial settings, and discards a pending decision if its settings or active connection change before it can be applied.
|
||||
|
||||
## 1.0.27
|
||||
|
||||
7th September, 2026
|
||||
|
||||
Reference in New Issue
Block a user