mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-21 18:17:05 +00:00
Adopt active Replicator ownership contracts
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type PouchDB from "pouchdb-core";
|
||||
import { fireAndForget } from "octagonal-wheels/promises";
|
||||
import { AbstractModule } from "@/modules/AbstractModule";
|
||||
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
||||
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||
import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
import { balanceChunkPurgedDBs } from "@vrtmrz/livesync-commonlib/compat/pouchdb/chunks";
|
||||
import { purgeUnreferencedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/chunks";
|
||||
@@ -23,7 +23,13 @@ import { clearHandlers } from "@vrtmrz/livesync-commonlib/compat/replication/Syn
|
||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import { MARK_LOG_NETWORK_ERROR } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||
import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings.ts";
|
||||
import { NO_INTERACTION, type ReplicationInteraction } from "@vrtmrz/livesync-commonlib/replication";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
NO_INTERACTION,
|
||||
REMOTE_RESOURCE_KINDS,
|
||||
type ReplicationFailureRequest,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource.ts";
|
||||
|
||||
function isOnlineAndCanReplicate(
|
||||
errorManager: UnresolvedErrorManager,
|
||||
@@ -38,31 +44,36 @@ function isOnlineAndCanReplicate(
|
||||
errorManager.clearError(errorMessage);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
async function canReplicateWithPBKDF2(
|
||||
/** Refresh and validate the selected central provider's owned Security Seed resource. */
|
||||
async function canReplicateWithSecuritySeed(
|
||||
errorManager: UnresolvedErrorManager,
|
||||
host: NecessaryServices<"replicator" | "setting", never>,
|
||||
showMessage: boolean
|
||||
): Promise<boolean> {
|
||||
const currentSettings = host.services.setting.currentSettings();
|
||||
// TODO: check using PBKDF2 salt?
|
||||
const errorMessage = $msg("Replicator.Message.InitialiseFatalError");
|
||||
const replicator = host.services.replicator.getActiveReplicator();
|
||||
if (!replicator) {
|
||||
errorManager.showError(errorMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
errorManager.clearError(errorMessage);
|
||||
// Showing message is false: that because be shown here. (And it is a fatal error, no way to hide it).
|
||||
// tagged as network error at beginning for error filtering with NetworkWarningStyles
|
||||
const ensureMessage = `${MARK_LOG_NETWORK_ERROR}Failed to initialise the encryption key, preventing replication.`;
|
||||
// A remote database rebuild replaces the Security Seed while this process may still hold the previous one.
|
||||
const ensureResult = await replicator.ensurePBKDF2Salt(currentSettings, showMessage, false);
|
||||
if (!ensureResult) {
|
||||
try {
|
||||
const resource = await host.services.replicator.createRemoteResource(
|
||||
REMOTE_RESOURCE_KINDS.SECURITY_SEED,
|
||||
currentSettings
|
||||
);
|
||||
if (!resource) {
|
||||
errorManager.showError(errorMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
errorManager.clearError(errorMessage);
|
||||
const seed = await withOwnedRemoteResource(resource, (ownedResource) => ownedResource.read());
|
||||
if (seed.length == 0) throw new Error("PBKDF2 salt (Security Seed) is empty");
|
||||
} catch (error) {
|
||||
Logger(error, LOG_LEVEL_VERBOSE);
|
||||
errorManager.showError(ensureMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
errorManager.clearError(ensureMessage);
|
||||
return ensureResult; // is true.
|
||||
return true;
|
||||
}
|
||||
|
||||
export class ModuleReplicator extends AbstractModule {
|
||||
@@ -158,8 +169,14 @@ export class ModuleReplicator extends AbstractModule {
|
||||
* database again, or purge unreferenced local chunks before accepting this device again.
|
||||
*
|
||||
* @param showMessage Whether to show the recovery choices as user-facing notices.
|
||||
* @param setting Detached settings used by the failed attempt.
|
||||
* @param expectedContext Publication which produced the compatibility rejection.
|
||||
*/
|
||||
async cleaned(showMessage: boolean) {
|
||||
async cleaned(
|
||||
showMessage: boolean,
|
||||
setting: ObsidianLiveSyncSettings,
|
||||
expectedContext: ReplicationFailureRequest["context"]
|
||||
) {
|
||||
Logger(`The remote database has been cleaned.`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||
await skipIfDuplicated("cleanup", async () => {
|
||||
const count = await purgeUnreferencedChunks(this.localDatabase.localDatabase, true);
|
||||
@@ -183,82 +200,97 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
}
|
||||
if (ret == CHOICE_CLEAN) {
|
||||
await this.services.replicator.runBoundedRemoteActivity(
|
||||
async () => {
|
||||
const replicator = this.services.replicator.getActiveReplicator();
|
||||
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
|
||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(
|
||||
this.settings,
|
||||
this.services.API.isMobile(),
|
||||
true
|
||||
);
|
||||
if (typeof remoteDB == "string") {
|
||||
Logger(remoteDB, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.clearCaches();
|
||||
// Perform the synchronisation once.
|
||||
const replicated = await this.services.replicator.runFiniteReplicationActivity(
|
||||
() => this.core.replicator.openReplication(this.settings, false, showMessage, true),
|
||||
{ label: "replication" }
|
||||
() =>
|
||||
this.services.replicator.runWithActiveReplicatorContext(async (context) => {
|
||||
if (context !== expectedContext) return;
|
||||
const replicator = context.replicator;
|
||||
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
|
||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(
|
||||
setting,
|
||||
this.services.API.isMobile(),
|
||||
true
|
||||
);
|
||||
if (replicated) {
|
||||
await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db);
|
||||
if (typeof remoteDB == "string") {
|
||||
Logger(remoteDB, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.clearCaches();
|
||||
await this.services.replicator.getActiveReplicator()?.markRemoteResolved(this.settings);
|
||||
Logger(
|
||||
"The local database has been cleaned up.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
} else {
|
||||
Logger(
|
||||
"Replication has been cancelled. Please try it again.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
// Perform the synchronisation once.
|
||||
const replicated = await this.services.replicator.runFiniteReplicationActivity(
|
||||
() => replicator.openOneShotReplication(setting, showMessage, false, "sync", true),
|
||||
{ label: "replication" }
|
||||
);
|
||||
if (replicated) {
|
||||
await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db);
|
||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||
this.localDatabase.clearCaches();
|
||||
await replicator.markRemoteResolved(setting);
|
||||
Logger(
|
||||
"The local database has been cleaned up.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
} else {
|
||||
Logger(
|
||||
"Replication has been cancelled. Please try it again.",
|
||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await remoteDB.close();
|
||||
}
|
||||
} finally {
|
||||
await remoteDB.db.close();
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ label: "database-cleanup" }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async onReplicationFailed(
|
||||
showMessageOrInteraction: boolean | ReplicationInteraction = false,
|
||||
interaction?: ReplicationInteraction
|
||||
): Promise<boolean> {
|
||||
// The typed ReplicationService passes the legacy visibility flag first
|
||||
// and the authority second. The authority is the source of truth for
|
||||
// recovery dialogues when it is present; retain the legacy boolean for
|
||||
// older callers which do not provide one.
|
||||
const showMessage = interaction
|
||||
? interaction.kind === "permitted" && interaction.permissions.failureRecovery
|
||||
: typeof showMessageOrInteraction === "boolean"
|
||||
? showMessageOrInteraction
|
||||
: showMessageOrInteraction.kind === "permitted" && showMessageOrInteraction.permissions.failureRecovery;
|
||||
const activeReplicator = this.services.replicator.getActiveReplicator();
|
||||
if (!activeReplicator) {
|
||||
Logger(`No active replicator found`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
private async onReplicationFailed(request: ReplicationFailureRequest): Promise<boolean> {
|
||||
const { context, interaction, outcome, setting, showMessage } = request;
|
||||
if (!showMessage) {
|
||||
// Automatic requests may report the failure, but they must never
|
||||
// enter tweak, lock, fetch, unlock, or cleanup dialogues.
|
||||
Logger(`Replication failed on an unattended path.`, LOG_LEVEL_INFO);
|
||||
return false;
|
||||
}
|
||||
if (activeReplicator.tweakSettingsMismatched && activeReplicator.preferredTweakValue) {
|
||||
await this.services.tweakValue.askResolvingMismatched(activeReplicator.preferredTweakValue);
|
||||
if (interaction.kind !== "permitted" || !interaction.permissions.failureRecovery) return false;
|
||||
const recovery = outcome.recoveryHint;
|
||||
if (!recovery) return false;
|
||||
if (
|
||||
recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH &&
|
||||
recovery.preferredTweakValue
|
||||
) {
|
||||
await this.services.tweakValue.askResolvingMismatched(
|
||||
recovery.preferredTweakValue,
|
||||
async (effectiveSetting) => {
|
||||
let updated = false;
|
||||
await this.services.replicator.runWithActiveReplicatorContext(async (activeContext) => {
|
||||
if (activeContext !== context) return;
|
||||
const candidate = activeContext.replicator as typeof activeContext.replicator & {
|
||||
setPreferredRemoteTweakSettings?: (
|
||||
setting: ObsidianLiveSyncSettings
|
||||
) => Promise<void>;
|
||||
};
|
||||
if (typeof candidate.setPreferredRemoteTweakSettings !== "function") return;
|
||||
await candidate.setPreferredRemoteTweakSettings({ ...effectiveSetting });
|
||||
updated = true;
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
if (activeReplicator.remoteLockedAndDeviceNotAccepted) {
|
||||
if (activeReplicator.remoteCleaned && usesLegacyIndexedDBAdapter(this.settings)) {
|
||||
await this.cleaned(showMessage);
|
||||
if (
|
||||
recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED ||
|
||||
recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED
|
||||
) {
|
||||
if (
|
||||
recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED &&
|
||||
usesLegacyIndexedDBAdapter(setting)
|
||||
) {
|
||||
await this.cleaned(showMessage, setting, context);
|
||||
} else {
|
||||
const message = $msg("Replicator.Dialogue.Locked.Message");
|
||||
const CHOICE_FETCH = $msg("Replicator.Dialogue.Locked.Action.Fetch");
|
||||
@@ -279,8 +311,19 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
this.services.appLifecycle.scheduleRestart();
|
||||
return false;
|
||||
} else if (ret == CHOICE_UNLOCK) {
|
||||
await activeReplicator.markRemoteResolved(this.settings);
|
||||
this._log($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE);
|
||||
let unlocked = false;
|
||||
await this.services.replicator.runWithActiveReplicatorContext(async (activeContext) => {
|
||||
if (activeContext !== context) return;
|
||||
const replicator = activeContext.replicator as typeof activeContext.replicator & {
|
||||
markRemoteResolved(setting: ObsidianLiveSyncSettings): Promise<void>;
|
||||
};
|
||||
if (typeof replicator.markRemoteResolved !== "function") return;
|
||||
await replicator.markRemoteResolved(setting);
|
||||
unlocked = true;
|
||||
});
|
||||
if (unlocked) {
|
||||
this._log($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -360,16 +403,20 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
||||
},
|
||||
serviceModules: {},
|
||||
});
|
||||
const canReplicateWithPBKDF2WithHost = canReplicateWithPBKDF2.bind(null, this._unresolvedErrorManager, {
|
||||
services: {
|
||||
context: services.context,
|
||||
replicator: services.replicator,
|
||||
setting: services.setting,
|
||||
},
|
||||
serviceModules: {},
|
||||
});
|
||||
const canReplicateWithSecuritySeedWithHost = canReplicateWithSecuritySeed.bind(
|
||||
null,
|
||||
this._unresolvedErrorManager,
|
||||
{
|
||||
services: {
|
||||
context: services.context,
|
||||
replicator: services.replicator,
|
||||
setting: services.setting,
|
||||
},
|
||||
serviceModules: {},
|
||||
}
|
||||
);
|
||||
services.replication.onBeforeReplicate.addHandler(isOnlineAndCanReplicateWithHost, 10);
|
||||
services.replication.onPrepareCentralRemoteReplication.addHandler(canReplicateWithPBKDF2WithHost);
|
||||
services.replication.onPrepareCentralRemoteReplication.addHandler(canReplicateWithSecuritySeedWithHost);
|
||||
// <-- End of handlers that can be separated.
|
||||
services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this), 100);
|
||||
services.replication.onReplicationFailed.addHandler(this.onReplicationFailed.bind(this));
|
||||
|
||||
@@ -2,6 +2,12 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import { EVENT_SETTING_SAVED, eventHub } from "@/common/events";
|
||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||
NO_INTERACTION,
|
||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
replicationFailed,
|
||||
} from "@vrtmrz/livesync-commonlib/replication";
|
||||
|
||||
const chunkMocks = vi.hoisted(() => ({
|
||||
purgeUnreferencedChunks: vi.fn(async (_db: unknown, countOnly: boolean) => (countOnly ? 2 : 0)),
|
||||
@@ -18,13 +24,15 @@ import { ModuleReplicator } from "./ModuleReplicator";
|
||||
|
||||
describe("ModuleReplicator", () => {
|
||||
it("refreshes the remote Security Seed before replication", async () => {
|
||||
const ensurePBKDF2Salt = vi.fn(async () => true);
|
||||
const read = vi.fn(async () => new Uint8Array([1]));
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const createRemoteResource = vi.fn(async () => ({ read, dispose }));
|
||||
let prepareCentralRemoteReplication: ((showMessage: boolean) => Promise<boolean>) | undefined;
|
||||
const services = {
|
||||
API: { isOnline: true },
|
||||
replicator: {
|
||||
onBeforeReplicatorPublication: { addHandler: vi.fn() },
|
||||
getActiveReplicator: () => ({ ensurePBKDF2Salt }),
|
||||
createRemoteResource,
|
||||
},
|
||||
setting: { currentSettings: () => ({}) },
|
||||
databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } },
|
||||
@@ -58,11 +66,15 @@ describe("ModuleReplicator", () => {
|
||||
|
||||
await prepareCentralRemoteReplication!(false);
|
||||
|
||||
expect(ensurePBKDF2Salt).toHaveBeenCalledWith({}, false, false);
|
||||
expect(createRemoteResource).toHaveBeenCalledWith("security-seed", {});
|
||||
expect(read).toHaveBeenCalledOnce();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps online and general pre-replication handlers for P2P while skipping central-remote Security Seed preparation", async () => {
|
||||
const ensurePBKDF2Salt = vi.fn(async () => true);
|
||||
const read = vi.fn(async () => new Uint8Array([1]));
|
||||
const dispose = vi.fn(async () => undefined);
|
||||
const createRemoteResource = vi.fn(async () => ({ read, dispose }));
|
||||
const handlers = new Map<number, (...args: unknown[]) => Promise<boolean | void>>();
|
||||
const centralRemoteHandlers: Array<(...args: unknown[]) => Promise<boolean | void>> = [];
|
||||
const addHandler = vi.fn((handler: (...args: unknown[]) => Promise<boolean | void>, priority?: number) => {
|
||||
@@ -72,7 +84,7 @@ describe("ModuleReplicator", () => {
|
||||
API: { isOnline: true },
|
||||
replicator: {
|
||||
onBeforeReplicatorPublication: { addHandler: vi.fn() },
|
||||
getActiveReplicator: () => ({ ensurePBKDF2Salt }),
|
||||
createRemoteResource,
|
||||
},
|
||||
setting: { currentSettings: () => ({}) },
|
||||
databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } },
|
||||
@@ -114,10 +126,12 @@ describe("ModuleReplicator", () => {
|
||||
await expect(general!(false)).resolves.toBe(true);
|
||||
|
||||
expect(generalBeforeReplicate).toHaveBeenCalledOnce();
|
||||
expect(ensurePBKDF2Salt).not.toHaveBeenCalled();
|
||||
expect(createRemoteResource).not.toHaveBeenCalled();
|
||||
|
||||
await expect(securitySeed!(false)).resolves.toBe(true);
|
||||
expect(ensurePBKDF2Salt).toHaveBeenCalledOnce();
|
||||
expect(createRemoteResource).toHaveBeenCalledOnce();
|
||||
expect(read).toHaveBeenCalledOnce();
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reprocesses stored documents when the normal-file target filters change", async () => {
|
||||
@@ -174,12 +188,23 @@ describe("ModuleReplicator", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("only permits recovery dialogue when the authority grants failure recovery", async () => {
|
||||
const askResolvingMismatched = vi.fn(async () => undefined);
|
||||
const activeReplicator = {
|
||||
it("uses the exact failed outcome and permits dialogue only with recovery authority", async () => {
|
||||
const askResolvingMismatched = vi.fn(async (..._args: unknown[]) => undefined);
|
||||
const failedSetPreferred = vi.fn(async (_setting: unknown) => undefined);
|
||||
const failedReplicator = { setPreferredRemoteTweakSettings: failedSetPreferred };
|
||||
const replacementSetPreferred = vi.fn(async (_setting: unknown) => undefined);
|
||||
const replacementReplicator = {
|
||||
tweakSettingsMismatched: true,
|
||||
preferredTweakValue: { customChunkSize: 60 },
|
||||
preferredTweakValue: { customChunkSize: 99 },
|
||||
setPreferredRemoteTweakSettings: replacementSetPreferred,
|
||||
};
|
||||
const context = { provider: {}, replicator: failedReplicator };
|
||||
const replacementContext = { provider: {}, replicator: replacementReplicator };
|
||||
const preferredTweakValue = { customChunkSize: 60 };
|
||||
const outcome = replicationFailed(new Error("mismatched"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
|
||||
preferredTweakValue,
|
||||
});
|
||||
const services = {
|
||||
context: createServiceContext(),
|
||||
API: {
|
||||
@@ -192,7 +217,12 @@ describe("ModuleReplicator", () => {
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: { addHandler: vi.fn() },
|
||||
},
|
||||
replicator: { getActiveReplicator: vi.fn(() => activeReplicator) },
|
||||
replicator: {
|
||||
getActiveReplicator: vi.fn(() => replacementReplicator),
|
||||
runWithActiveReplicatorContext: vi.fn(async (task: (context: unknown) => unknown) =>
|
||||
task(replacementContext)
|
||||
),
|
||||
},
|
||||
tweakValue: { askResolvingMismatched },
|
||||
};
|
||||
const core = {
|
||||
@@ -202,30 +232,137 @@ describe("ModuleReplicator", () => {
|
||||
} as any;
|
||||
const module = new ModuleReplicator(core);
|
||||
|
||||
await (module as any).onReplicationFailed(false);
|
||||
await (module as any).onReplicationFailed({
|
||||
context,
|
||||
setting: {},
|
||||
outcome,
|
||||
showMessage: false,
|
||||
interaction: NO_INTERACTION,
|
||||
});
|
||||
expect(askResolvingMismatched).not.toHaveBeenCalled();
|
||||
|
||||
await (module as any).onReplicationFailed(true, {
|
||||
kind: "permitted",
|
||||
permissions: {
|
||||
peerSelection: true,
|
||||
localPeerAdmission: true,
|
||||
configurationExchange: true,
|
||||
failureRecovery: false,
|
||||
await (module as any).onReplicationFailed({
|
||||
context,
|
||||
setting: {},
|
||||
outcome,
|
||||
showMessage: false,
|
||||
interaction: {
|
||||
kind: "permitted",
|
||||
permissions: { ...USER_INITIATED_REPLICATION_AUTHORITY.permissions, failureRecovery: false },
|
||||
},
|
||||
});
|
||||
expect(askResolvingMismatched).not.toHaveBeenCalled();
|
||||
|
||||
await (module as any).onReplicationFailed(true, {
|
||||
kind: "permitted",
|
||||
permissions: {
|
||||
peerSelection: true,
|
||||
localPeerAdmission: true,
|
||||
configurationExchange: true,
|
||||
failureRecovery: true,
|
||||
},
|
||||
await (module as any).onReplicationFailed({
|
||||
context,
|
||||
setting: {},
|
||||
outcome,
|
||||
showMessage: true,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
expect(askResolvingMismatched).toHaveBeenCalledOnce();
|
||||
expect(askResolvingMismatched).toHaveBeenCalledWith(preferredTweakValue, expect.any(Function));
|
||||
const updatePreferredRemote = askResolvingMismatched.mock.calls[0][1] as (
|
||||
setting: Record<string, unknown>
|
||||
) => Promise<boolean>;
|
||||
await expect(updatePreferredRemote({ customChunkSize: 64 } as any)).resolves.toBe(false);
|
||||
expect(failedSetPreferred).not.toHaveBeenCalled();
|
||||
expect(replacementSetPreferred).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes a mismatch decision only through the still-active failed publication", async () => {
|
||||
const setPreferredRemoteTweakSettings = vi.fn(async (_setting: unknown) => undefined);
|
||||
const context = { provider: {}, replicator: { setPreferredRemoteTweakSettings } };
|
||||
let updatePreferredRemote:
|
||||
| ((setting: Record<string, unknown>) => Promise<boolean>)
|
||||
| undefined;
|
||||
const askResolvingMismatched = vi.fn(
|
||||
async (_preferred: unknown, update: (setting: Record<string, unknown>) => Promise<boolean>) => {
|
||||
updatePreferredRemote = update;
|
||||
}
|
||||
);
|
||||
const services = {
|
||||
context: createServiceContext(),
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
appLifecycle: { getUnresolvedMessages: { addHandler: vi.fn() } },
|
||||
replicator: {
|
||||
runWithActiveReplicatorContext: vi.fn(async (task: (activeContext: unknown) => unknown) =>
|
||||
task(context)
|
||||
),
|
||||
},
|
||||
tweakValue: { askResolvingMismatched },
|
||||
};
|
||||
const module = new ModuleReplicator({ _services: services, services, settings: {} } as any);
|
||||
|
||||
await (module as any).onReplicationFailed({
|
||||
context,
|
||||
setting: {},
|
||||
outcome: replicationFailed(new Error("mismatched"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
|
||||
preferredTweakValue: { customChunkSize: 60 },
|
||||
}),
|
||||
showMessage: true,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
|
||||
const effectiveSetting = { customChunkSize: 64 };
|
||||
await expect(updatePreferredRemote?.(effectiveSetting)).resolves.toBe(true);
|
||||
expect(setPreferredRemoteTweakSettings).toHaveBeenCalledWith(effectiveSetting);
|
||||
expect(setPreferredRemoteTweakSettings.mock.calls[0][0]).not.toBe(effectiveSetting);
|
||||
});
|
||||
|
||||
it("does not apply an unlock selected for a replaced failed publication", async () => {
|
||||
const failedMarkResolved = vi.fn(async () => undefined);
|
||||
const replacementMarkResolved = vi.fn(async () => undefined);
|
||||
const failedContext = { provider: {}, replicator: { markRemoteResolved: failedMarkResolved } };
|
||||
const replacementContext = { provider: {}, replicator: { markRemoteResolved: replacementMarkResolved } };
|
||||
const runWithActiveReplicatorContext = vi.fn(async (task: (context: unknown) => unknown) =>
|
||||
task(replacementContext)
|
||||
);
|
||||
const services = {
|
||||
context: createServiceContext(),
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
appLifecycle: {
|
||||
getUnresolvedMessages: { addHandler: vi.fn() },
|
||||
scheduleRestart: vi.fn(),
|
||||
},
|
||||
replicator: { runWithActiveReplicatorContext },
|
||||
};
|
||||
const core = {
|
||||
_services: services,
|
||||
services,
|
||||
settings: {},
|
||||
confirm: {
|
||||
askSelectStringDialogue: vi.fn(async (_message: string, choices: string[]) => choices[1]),
|
||||
},
|
||||
rebuilder: { scheduleFetch: vi.fn() },
|
||||
} as any;
|
||||
const module = new ModuleReplicator(core);
|
||||
|
||||
await (module as any).onReplicationFailed({
|
||||
context: failedContext,
|
||||
setting: {},
|
||||
outcome: replicationFailed(new Error("locked"), {
|
||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
|
||||
}),
|
||||
showMessage: true,
|
||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||
});
|
||||
|
||||
expect(runWithActiveReplicatorContext).toHaveBeenCalledOnce();
|
||||
expect(failedMarkResolved).not.toHaveBeenCalled();
|
||||
expect(replacementMarkResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -240,14 +377,20 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", (
|
||||
}
|
||||
});
|
||||
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
||||
const openReplication = vi.fn(async () => true);
|
||||
const openOneShotReplication = vi.fn(async () => true);
|
||||
const remoteDatabase = {
|
||||
close: vi.fn(async () => undefined),
|
||||
};
|
||||
const close = vi.fn(async () => undefined);
|
||||
const activeReplicator = Object.assign(new LiveSyncCouchDBReplicator({} as any), {
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
|
||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase, close })),
|
||||
openOneShotReplication,
|
||||
markRemoteResolved: vi.fn(async () => undefined),
|
||||
});
|
||||
const expectedContext = { provider: {}, replicator: activeReplicator };
|
||||
const runWithActiveReplicatorContext = vi.fn(async (task: (context: unknown) => unknown) =>
|
||||
task(expectedContext)
|
||||
);
|
||||
const services = {
|
||||
context: createServiceContext(),
|
||||
API: {
|
||||
@@ -266,6 +409,7 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", (
|
||||
getActiveReplicator: vi.fn(() => activeReplicator),
|
||||
runBoundedRemoteActivity,
|
||||
runFiniteReplicationActivity,
|
||||
runWithActiveReplicatorContext,
|
||||
},
|
||||
};
|
||||
const localDatabase = {
|
||||
@@ -278,11 +422,10 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", (
|
||||
settings: {},
|
||||
localDatabase,
|
||||
confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") },
|
||||
replicator: { openReplication },
|
||||
} as any;
|
||||
const module = new ModuleReplicator(core);
|
||||
|
||||
await module.cleaned(true);
|
||||
await module.cleaned(true, {} as ObsidianLiveSyncSettings, expectedContext as never);
|
||||
|
||||
expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||
label: "database-cleanup",
|
||||
@@ -290,12 +433,13 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", (
|
||||
expect(runFiniteReplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||
label: "replication",
|
||||
});
|
||||
expect(openReplication).toHaveBeenCalledOnce();
|
||||
expect(openReplication.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]);
|
||||
expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce();
|
||||
expect(remoteDatabase.close).toHaveBeenCalledOnce();
|
||||
expect(remoteDatabase.close.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
expect(runWithActiveReplicatorContext).toHaveBeenCalledOnce();
|
||||
expect(openOneShotReplication).toHaveBeenCalledOnce();
|
||||
expect(openOneShotReplication.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
activityFinished.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce();
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(close.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,19 +232,24 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
return CHOICES[retKey];
|
||||
}
|
||||
|
||||
async _askResolvingMismatchedTweaks(): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
||||
if (!this.core.replicator.tweakSettingsMismatched) {
|
||||
return "OK";
|
||||
}
|
||||
const tweaks = this.core.replicator.preferredTweakValue;
|
||||
if (!tweaks) {
|
||||
return "IGNORE";
|
||||
}
|
||||
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(tweaks);
|
||||
async _askResolvingMismatchedTweaks(
|
||||
preferredSource: TweakValues,
|
||||
updatePreferredRemote?: (setting: ObsidianLiveSyncSettings) => Promise<boolean>
|
||||
): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
||||
const [conf, rebuildRequired] =
|
||||
await this.services.tweakValue.checkAndAskResolvingMismatched(preferredSource);
|
||||
if (!conf) return "IGNORE";
|
||||
|
||||
const updateRemote = async () => {
|
||||
if (updatePreferredRemote) return await updatePreferredRemote(this.settings);
|
||||
const candidate = this.core.replicator;
|
||||
if (typeof candidate.setPreferredRemoteTweakSettings !== "function") return false;
|
||||
await candidate.setPreferredRemoteTweakSettings(this.settings);
|
||||
return true;
|
||||
};
|
||||
|
||||
if (conf === true) {
|
||||
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
|
||||
if (!(await updateRemote())) return "IGNORE";
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$rebuildRemote();
|
||||
}
|
||||
@@ -261,7 +266,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
||||
// chunk-generation managers now so hash and splitter changes take effect before retrying.
|
||||
await this.localDatabase.managers.reinitialise();
|
||||
}
|
||||
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
|
||||
if (!(await updateRemote())) return "IGNORE";
|
||||
if (rebuildRequired) {
|
||||
await this.core.rebuilder.$fetchLocal();
|
||||
}
|
||||
|
||||
@@ -272,13 +272,18 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
||||
reinitialise.mockImplementation(async () => {
|
||||
calls.push("reinitialise");
|
||||
});
|
||||
const updatePreferredRemote = vi.fn(async () => {
|
||||
calls.push("set-preferred");
|
||||
return true;
|
||||
});
|
||||
|
||||
const result = await module._askResolvingMismatchedTweaks();
|
||||
const result = await module._askResolvingMismatchedTweaks(preferred, updatePreferredRemote);
|
||||
|
||||
expect(result).toBe("CHECKAGAIN");
|
||||
expect(core.settings).toBe(initialSettings);
|
||||
expect(core.settings.hashAlg).toBe("xxhash32");
|
||||
expect(calls).toEqual(["save", "reinitialise", "set-preferred"]);
|
||||
expect(core.replicator.setPreferredRemoteTweakSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,16 @@ type ErrorInfo = {
|
||||
|
||||
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
|
||||
|
||||
interface CompromisedChunkCounter {
|
||||
countCompromisedChunks(): Promise<number | boolean>;
|
||||
}
|
||||
|
||||
function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter {
|
||||
return (
|
||||
value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
||||
constructor(
|
||||
core: LiveSyncCore,
|
||||
@@ -253,7 +263,10 @@ export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
||||
// Check local database for compromised chunks
|
||||
const localCompromised = await countCompromisedChunks(this.localDatabase.localDatabase);
|
||||
const remote = this.services.replicator.getActiveReplicator();
|
||||
const remoteCompromised = this.services.API.isOnline ? await remote?.countCompromisedChunks() : 0;
|
||||
const remoteCompromised =
|
||||
this.services.API.isOnline && hasCompromisedChunkCounter(remote)
|
||||
? await remote.countCompromisedChunks()
|
||||
: 0;
|
||||
if (localCompromised === false) {
|
||||
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
|
||||
@@ -17,8 +17,8 @@ export function paneMaintenance(
|
||||
paneEl: HTMLElement,
|
||||
{ addPanel }: PageFunctions
|
||||
): void {
|
||||
const isRemoteLockedAndDeviceNotAccepted = () => this.core?.replicator?.remoteLockedAndDeviceNotAccepted;
|
||||
const isRemoteLocked = () => this.core?.replicator?.remoteLocked;
|
||||
const isRemoteLockedAndDeviceNotAccepted = () => !!this.core?.replicator?.remoteLockedAndDeviceNotAccepted;
|
||||
const isRemoteLocked = () => !!this.core?.replicator?.remoteLocked;
|
||||
// if (this.plugin?.replicator?.remoteLockedAndDeviceNotAccepted) {
|
||||
this.createEl(
|
||||
paneEl,
|
||||
|
||||
@@ -37,7 +37,11 @@ describe("ObsidianReplicatorService", () => {
|
||||
allowSleepDuringSynchronisationOnDesktop: false,
|
||||
}),
|
||||
},
|
||||
appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() },
|
||||
appLifecycleService: {
|
||||
onSuspending: handler(),
|
||||
onUnload: handler(),
|
||||
getUnresolvedMessages: handler(),
|
||||
},
|
||||
databaseEventService: {
|
||||
onResetDatabase: handler(),
|
||||
onDatabaseInitialisation: handler(),
|
||||
@@ -73,7 +77,11 @@ describe("ObsidianReplicatorService", () => {
|
||||
allowSleepDuringSynchronisationOnDesktop: true,
|
||||
}),
|
||||
},
|
||||
appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() },
|
||||
appLifecycleService: {
|
||||
onSuspending: handler(),
|
||||
onUnload: handler(),
|
||||
getUnresolvedMessages: handler(),
|
||||
},
|
||||
databaseEventService: {
|
||||
onResetDatabase: handler(),
|
||||
onDatabaseInitialisation: handler(),
|
||||
|
||||
Reference in New Issue
Block a user