mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-26 02:53:21 +00:00
Compose replication lifecycle without a legacy module
This commit is contained in:
1 parent
a5756503a2
commit
72f033fca4
14 files changed
+939
-937
No files matched your search
@@ -26,7 +26,6 @@ import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/comp
|
|||||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectableServiceHub";
|
||||||
import { AbstractModule } from "./modules/AbstractModule";
|
import { AbstractModule } from "./modules/AbstractModule";
|
||||||
import { ModuleReplicator } from "./modules/core/ModuleReplicator";
|
|
||||||
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
import { ModuleConflictChecker } from "./modules/coreFeatures/ModuleConflictChecker";
|
||||||
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
import { ModuleConflictResolver } from "./modules/coreFeatures/ModuleConflictResolver";
|
||||||
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
import { ModuleResolvingMismatchedTweaks } from "./modules/coreFeatures/ModuleResolveMismatchedTweaks";
|
||||||
@@ -37,6 +36,7 @@ import { usePrepareDatabaseForUse } from "@vrtmrz/livesync-commonlib/compat/serv
|
|||||||
import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type";
|
import type { Constructor } from "@vrtmrz/livesync-commonlib/compat/common/utils.type";
|
||||||
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
|
import { useReplicationScheduling, type ReplicationSchedulingControl } from "./serviceFeatures/replicationScheduling";
|
||||||
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
|
import { createCentralReplicatorProviderDefinitions } from "./common/replicatorProviders";
|
||||||
|
import { useReplicationFeature } from "./serviceFeatures/replication";
|
||||||
|
|
||||||
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
/** Focused views returned by serviceFeatures which the host may consume during composition. */
|
||||||
export interface LiveSyncCoreFeatureViews {
|
export interface LiveSyncCoreFeatureViews {
|
||||||
@@ -101,6 +101,9 @@ export class LiveSyncBaseCore<
|
|||||||
for (const addOn of addOns) {
|
for (const addOn of addOns) {
|
||||||
this._registerAddOn(addOn);
|
this._registerAddOn(addOn);
|
||||||
}
|
}
|
||||||
|
// Preserve the former ModuleReplicator lifecycle-handler order:
|
||||||
|
// host features and add-ons first, then replication, then legacy modules.
|
||||||
|
useReplicationFeature(this);
|
||||||
this.bindModuleFunctions();
|
this.bindModuleFunctions();
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -161,7 +164,6 @@ export class LiveSyncBaseCore<
|
|||||||
public registerModules(extraModules: AbstractModule[] = []) {
|
public registerModules(extraModules: AbstractModule[] = []) {
|
||||||
this._registerModule(new ModuleLiveSyncMain(this));
|
this._registerModule(new ModuleLiveSyncMain(this));
|
||||||
this._registerModule(new ModuleConflictChecker(this));
|
this._registerModule(new ModuleConflictChecker(this));
|
||||||
this._registerModule(new ModuleReplicator(this));
|
|
||||||
this._registerModule(new ModuleConflictResolver(this));
|
this._registerModule(new ModuleConflictResolver(this));
|
||||||
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
|
this._registerModule(new ModuleResolvingMismatchedTweaks(this));
|
||||||
this._registerModule(new ModuleBasicMenu(this));
|
this._registerModule(new ModuleBasicMenu(this));
|
||||||
|
|||||||
@@ -143,8 +143,12 @@ export async function createCompressionBenchmarkDataset(options: {
|
|||||||
);
|
);
|
||||||
await copyRepositoryFile("json", "package.json", "package.json");
|
await copyRepositoryFile("json", "package.json", "package.json");
|
||||||
await copyRepositoryFile("json", "manifest.json", "manifest.json");
|
await copyRepositoryFile("json", "manifest.json", "manifest.json");
|
||||||
await copyRepositoryFile("ts", "src/modules/core/ModuleReplicator.ts", "ModuleReplicator.ts");
|
await copyRepositoryFile("ts", "src/serviceFeatures/replication/index.ts", "replicationFeature.ts");
|
||||||
await copyRepositoryFile("ts", "src/modules/core/ReplicateResultProcessor.ts", "ReplicateResultProcessor.ts");
|
await copyRepositoryFile(
|
||||||
|
"ts",
|
||||||
|
"src/serviceFeatures/replication/ReplicateResultProcessor.ts",
|
||||||
|
"ReplicateResultProcessor.ts"
|
||||||
|
);
|
||||||
|
|
||||||
const markdownBytes = await Deno.readFile(join(repositoryRoot, "docs/settings.md"));
|
const markdownBytes = await Deno.readFile(join(repositoryRoot, "docs/settings.md"));
|
||||||
const gzipPath = join(datasetRoot, "gz", "settings.md.gz");
|
const gzipPath = join(datasetRoot, "gz", "settings.md.gz");
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ Deno.test("benchmark cases record scope and limitations for paper use", () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("CouchDB latency proxy applies half the requested RTT in each direction", async () => {
|
Deno.test("CouchDB latency proxy applies half the requested RTT in each direction", async () => {
|
||||||
const backendPort = getFreePort();
|
const backendPort = getFreePort();
|
||||||
const proxyPort = getFreePort();
|
const proxyPort = getFreePort();
|
||||||
@@ -156,8 +155,8 @@ Deno.test("compression benchmark dataset covers representative file kinds determ
|
|||||||
"images/quick-setup/guide-quick-setup-first-setup-uri.png",
|
"images/quick-setup/guide-quick-setup-first-setup-uri.png",
|
||||||
"package.json",
|
"package.json",
|
||||||
"manifest.json",
|
"manifest.json",
|
||||||
"src/modules/core/ModuleReplicator.ts",
|
"src/serviceFeatures/replication/index.ts",
|
||||||
"src/modules/core/ReplicateResultProcessor.ts",
|
"src/serviceFeatures/replication/ReplicateResultProcessor.ts",
|
||||||
];
|
];
|
||||||
try {
|
try {
|
||||||
for (const [index, relativePath] of repositoryFiles.entries()) {
|
for (const [index, relativePath] of repositoryFiles.entries()) {
|
||||||
|
|||||||
@@ -1,424 +0,0 @@
|
|||||||
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, 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";
|
|
||||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
|
||||||
import {
|
|
||||||
type EntryDoc,
|
|
||||||
type ObsidianLiveSyncSettings,
|
|
||||||
type RemoteType,
|
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
|
||||||
|
|
||||||
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
|
||||||
import { EVENT_FILE_SAVED, EVENT_SETTING_SAVED, eventHub } from "@/common/events";
|
|
||||||
|
|
||||||
import { $msg } from "@/common/translation";
|
|
||||||
import type { LiveSyncCore } from "@/main";
|
|
||||||
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
|
|
||||||
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
|
|
||||||
import { clearHandlers } from "@vrtmrz/livesync-commonlib/compat/replication/SyncParamsHandler";
|
|
||||||
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 {
|
|
||||||
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,
|
|
||||||
host: NecessaryServices<"API", never>,
|
|
||||||
showMessage: boolean
|
|
||||||
): Promise<boolean> {
|
|
||||||
const errorMessage = "Network is offline";
|
|
||||||
if (!host.services.API.isOnline) {
|
|
||||||
errorManager.showError(errorMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
|
||||||
return Promise.resolve(false);
|
|
||||||
}
|
|
||||||
errorManager.clearError(errorMessage);
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
/** 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();
|
|
||||||
const errorMessage = $msg("Replicator.Message.InitialiseFatalError");
|
|
||||||
// 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.`;
|
|
||||||
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 true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ModuleReplicator extends AbstractModule {
|
|
||||||
_replicatorType?: RemoteType;
|
|
||||||
|
|
||||||
processor: ReplicateResultProcessor = new ReplicateResultProcessor(this);
|
|
||||||
private _unresolvedErrorManager: UnresolvedErrorManager = new UnresolvedErrorManager(
|
|
||||||
this.core.services.appLifecycle,
|
|
||||||
this.core.services.context.events
|
|
||||||
);
|
|
||||||
|
|
||||||
clearErrors() {
|
|
||||||
this._unresolvedErrorManager.clearErrors();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _normalFileReflectionFilterSignature: string | undefined;
|
|
||||||
|
|
||||||
private getNormalFileReflectionFilterSignature(
|
|
||||||
settings: Pick<
|
|
||||||
ObsidianLiveSyncSettings,
|
|
||||||
| "handleFilenameCaseSensitive"
|
|
||||||
| "ignoreFiles"
|
|
||||||
| "maxMTimeForReflectEvents"
|
|
||||||
| "syncIgnoreRegEx"
|
|
||||||
| "syncInternalFiles"
|
|
||||||
| "syncMaxSizeInMB"
|
|
||||||
| "syncOnlyRegEx"
|
|
||||||
| "useIgnoreFiles"
|
|
||||||
>
|
|
||||||
): string {
|
|
||||||
return JSON.stringify({
|
|
||||||
handleFilenameCaseSensitive: settings.handleFilenameCaseSensitive ?? false,
|
|
||||||
ignoreFiles: settings.ignoreFiles ?? "",
|
|
||||||
maxMTimeForReflectEvents: settings.maxMTimeForReflectEvents ?? 0,
|
|
||||||
syncIgnoreRegEx: settings.syncIgnoreRegEx ?? "",
|
|
||||||
syncInternalFiles: settings.syncInternalFiles ?? false,
|
|
||||||
syncMaxSizeInMB: settings.syncMaxSizeInMB ?? 0,
|
|
||||||
syncOnlyRegEx: settings.syncOnlyRegEx ?? "",
|
|
||||||
useIgnoreFiles: settings.useIgnoreFiles ?? false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _everyOnloadAfterLoadSettings(): Promise<boolean> {
|
|
||||||
this._normalFileReflectionFilterSignature = this.getNormalFileReflectionFilterSignature(this.settings);
|
|
||||||
eventHub.onEvent(EVENT_FILE_SAVED, () => {
|
|
||||||
if (this.settings.syncOnSave && !this.core.services.appLifecycle.isSuspended()) {
|
|
||||||
scheduleTask("perform-replicate-after-save", 250, () =>
|
|
||||||
this.services.replication.replicateUnattendedByEvent({
|
|
||||||
trigger: "database-event",
|
|
||||||
interaction: NO_INTERACTION,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
eventHub.onEvent(EVENT_SETTING_SAVED, (setting) => {
|
|
||||||
const previousReflectionFilter = this._normalFileReflectionFilterSignature;
|
|
||||||
const nextReflectionFilter = this.getNormalFileReflectionFilterSignature(setting);
|
|
||||||
this._normalFileReflectionFilterSignature = nextReflectionFilter;
|
|
||||||
if (this.core.settings.suspendParseReplicationResult) {
|
|
||||||
this.processor.suspend();
|
|
||||||
} else {
|
|
||||||
this.processor.resume();
|
|
||||||
}
|
|
||||||
if (previousReflectionFilter !== undefined && previousReflectionFilter !== nextReflectionFilter) {
|
|
||||||
fireAndForget(() => this.processor.reprocessStoredDocuments());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onBeforeReplicatorPublication(): Promise<boolean> {
|
|
||||||
// Clear key-derivation handlers before the candidate Replicator becomes active.
|
|
||||||
clearHandlers();
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
_everyOnDatabaseInitialized(showNotice: boolean): Promise<boolean> {
|
|
||||||
fireAndForget(() => this.processor.restoreFromSnapshotOnce());
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
async _everyBeforeReplicate(showMessage: boolean): Promise<boolean> {
|
|
||||||
await this.processor.restoreFromSnapshotOnce();
|
|
||||||
this.clearErrors();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reconciles an IndexedDB-backed local database after replication reports that the remote was cleaned.
|
|
||||||
*
|
|
||||||
* The remote milestone remains a supported compatibility signal. The user can either fetch the remote
|
|
||||||
* 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,
|
|
||||||
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);
|
|
||||||
const message = `The remote database has been cleaned up.
|
|
||||||
To synchronize, this device must be also cleaned up. ${count} chunk(s) will be erased from this device.
|
|
||||||
However, If there are many chunks to be deleted, maybe fetching again is faster.
|
|
||||||
We will lose the history of this device if we fetch the remote database again.
|
|
||||||
Even if you choose to clean up, you will see this option again if you exit Obsidian and then synchronise again.`;
|
|
||||||
const CHOICE_FETCH = "Fetch again";
|
|
||||||
const CHOICE_CLEAN = "Cleanup";
|
|
||||||
const CHOICE_DISMISS = "Dismiss";
|
|
||||||
const ret = await this.core.confirm.confirmWithMessage(
|
|
||||||
"Cleaned",
|
|
||||||
message,
|
|
||||||
[CHOICE_FETCH, CHOICE_CLEAN, CHOICE_DISMISS],
|
|
||||||
CHOICE_DISMISS,
|
|
||||||
30
|
|
||||||
);
|
|
||||||
if (ret == CHOICE_FETCH) {
|
|
||||||
await this.core.rebuilder.$performRebuildDB("localOnly");
|
|
||||||
}
|
|
||||||
if (ret == CHOICE_CLEAN) {
|
|
||||||
await this.services.replicator.runBoundedRemoteActivity(
|
|
||||||
() =>
|
|
||||||
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 (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(
|
|
||||||
() => 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();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ label: "database-cleanup" }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (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 (
|
|
||||||
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");
|
|
||||||
const CHOICE_DISMISS = $msg("Replicator.Dialogue.Locked.Action.Dismiss");
|
|
||||||
const CHOICE_UNLOCK = $msg("Replicator.Dialogue.Locked.Action.Unlock");
|
|
||||||
const ret = await this.core.confirm.askSelectStringDialogue(
|
|
||||||
message,
|
|
||||||
[CHOICE_FETCH, CHOICE_UNLOCK, CHOICE_DISMISS],
|
|
||||||
{
|
|
||||||
title: $msg("Replicator.Dialogue.Locked.Title"),
|
|
||||||
defaultAction: CHOICE_DISMISS,
|
|
||||||
timeout: 60,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (ret == CHOICE_FETCH) {
|
|
||||||
this._log($msg("Replicator.Dialogue.Locked.Message.Fetch"), LOG_LEVEL_NOTICE);
|
|
||||||
await this.core.rebuilder.scheduleFetch();
|
|
||||||
this.services.appLifecycle.scheduleRestart();
|
|
||||||
return false;
|
|
||||||
} else if (ret == CHOICE_UNLOCK) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: Check again and true/false return. This will be the result for performReplication.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// private async _replicateByEvent(): Promise<boolean | void> {
|
|
||||||
// const least = this.settings.syncMinimumInterval;
|
|
||||||
// if (least > 0) {
|
|
||||||
// return rateLimitedSharedExecution(KEY_REPLICATION_ON_EVENT, least, async () => {
|
|
||||||
// return await this.services.replication.replicate();
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// return await shareRunningResult(`replication`, () => this.services.replication.replicate());
|
|
||||||
// }
|
|
||||||
|
|
||||||
_parseReplicationResult(docs: Array<PouchDB.Core.ExistingDocument<EntryDoc>>): Promise<boolean> {
|
|
||||||
this.processor.enqueueAll(docs);
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// _everyBeforeSuspendProcess(): Promise<boolean> {
|
|
||||||
// this.core.replicator?.closeReplication();
|
|
||||||
// return Promise.resolve(true);
|
|
||||||
// }
|
|
||||||
|
|
||||||
// private async _replicateAllToServer(
|
|
||||||
// showingNotice: boolean = false,
|
|
||||||
// sendChunksInBulkDisabled: boolean = false
|
|
||||||
// ): Promise<boolean> {
|
|
||||||
// if (!this.services.appLifecycle.isReady()) return false;
|
|
||||||
// if (!(await this.services.replication.onBeforeReplicate(showingNotice))) {
|
|
||||||
// Logger($msg("Replicator.Message.SomeModuleFailed"), LOG_LEVEL_NOTICE);
|
|
||||||
// return false;
|
|
||||||
// }
|
|
||||||
// if (!sendChunksInBulkDisabled) {
|
|
||||||
// if (this.core.replicator instanceof LiveSyncCouchDBReplicator) {
|
|
||||||
// if (
|
|
||||||
// (await this.core.confirm.askYesNoDialog("Do you want to send all chunks before replication?", {
|
|
||||||
// defaultOption: "No",
|
|
||||||
// timeout: 20,
|
|
||||||
// })) == "yes"
|
|
||||||
// ) {
|
|
||||||
// await this.core.replicator.sendChunks(this.core.settings, undefined, true, 0);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// const ret = await this.core.replicator.replicateAllToServer(this.settings, showingNotice);
|
|
||||||
// if (ret) return true;
|
|
||||||
// const checkResult = await this.services.replication.checkConnectionFailure();
|
|
||||||
// if (checkResult == "CHECKAGAIN") return await this.services.remote.replicateAllToRemote(showingNotice);
|
|
||||||
// return !checkResult;
|
|
||||||
// }
|
|
||||||
// async _replicateAllFromServer(showingNotice: boolean = false): Promise<boolean> {
|
|
||||||
// if (!this.services.appLifecycle.isReady()) return false;
|
|
||||||
// const ret = await this.core.replicator.replicateAllFromServer(this.settings, showingNotice);
|
|
||||||
// if (ret) return true;
|
|
||||||
// const checkResult = await this.services.replication.checkConnectionFailure();
|
|
||||||
// if (checkResult == "CHECKAGAIN") return await this.services.remote.replicateAllFromRemote(showingNotice);
|
|
||||||
// return !checkResult;
|
|
||||||
// }
|
|
||||||
|
|
||||||
override onBindFunction(core: LiveSyncCore, services: typeof core.services): void {
|
|
||||||
services.replicator.onBeforeReplicatorPublication.addHandler(this._onBeforeReplicatorPublication.bind(this));
|
|
||||||
services.databaseEvents.onDatabaseInitialised.addHandler(this._everyOnDatabaseInitialized.bind(this));
|
|
||||||
services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this));
|
|
||||||
services.replication.parseSynchroniseResult.addHandler(this._parseReplicationResult.bind(this));
|
|
||||||
|
|
||||||
// --> These handlers can be separated.
|
|
||||||
const isOnlineAndCanReplicateWithHost = isOnlineAndCanReplicate.bind(null, this._unresolvedErrorManager, {
|
|
||||||
services: {
|
|
||||||
context: services.context,
|
|
||||||
API: services.API,
|
|
||||||
},
|
|
||||||
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(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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
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)),
|
|
||||||
balanceChunkPurgedDBs: vi.fn(async () => undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/chunks", () => chunkMocks);
|
|
||||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
|
||||||
LiveSyncCouchDBReplicator: class {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
|
||||||
import { ModuleReplicator } from "./ModuleReplicator";
|
|
||||||
|
|
||||||
describe("ModuleReplicator", () => {
|
|
||||||
it("refreshes the remote Security Seed before replication", async () => {
|
|
||||||
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() },
|
|
||||||
createRemoteResource,
|
|
||||||
},
|
|
||||||
setting: { currentSettings: () => ({}) },
|
|
||||||
databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } },
|
|
||||||
appLifecycle: { onSettingLoaded: { addHandler: vi.fn() } },
|
|
||||||
replication: {
|
|
||||||
parseSynchroniseResult: { addHandler: vi.fn() },
|
|
||||||
onBeforeReplicate: { addHandler: vi.fn() },
|
|
||||||
onPrepareCentralRemoteReplication: {
|
|
||||||
addHandler: vi.fn((handler: (showMessage: boolean) => Promise<boolean>) => {
|
|
||||||
prepareCentralRemoteReplication = handler;
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
onReplicationFailed: { addHandler: vi.fn() },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const module = {
|
|
||||||
_unresolvedErrorManager: {
|
|
||||||
showError: vi.fn(),
|
|
||||||
clearError: vi.fn(),
|
|
||||||
},
|
|
||||||
_onBeforeReplicatorPublication: vi.fn(),
|
|
||||||
_everyOnDatabaseInitialized: vi.fn(),
|
|
||||||
_everyOnloadAfterLoadSettings: vi.fn(),
|
|
||||||
_parseReplicationResult: vi.fn(),
|
|
||||||
_everyBeforeReplicate: vi.fn(),
|
|
||||||
onReplicationFailed: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
ModuleReplicator.prototype.onBindFunction.call(module, {} as never, services as never);
|
|
||||||
expect(prepareCentralRemoteReplication).toBeDefined();
|
|
||||||
|
|
||||||
await prepareCentralRemoteReplication!(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 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) => {
|
|
||||||
handlers.set(priority ?? 0, handler);
|
|
||||||
});
|
|
||||||
const services = {
|
|
||||||
API: { isOnline: true },
|
|
||||||
replicator: {
|
|
||||||
onBeforeReplicatorPublication: { addHandler: vi.fn() },
|
|
||||||
createRemoteResource,
|
|
||||||
},
|
|
||||||
setting: { currentSettings: () => ({}) },
|
|
||||||
databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } },
|
|
||||||
appLifecycle: { onSettingLoaded: { addHandler: vi.fn() } },
|
|
||||||
replication: {
|
|
||||||
parseSynchroniseResult: { addHandler: vi.fn() },
|
|
||||||
onBeforeReplicate: { addHandler },
|
|
||||||
onPrepareCentralRemoteReplication: {
|
|
||||||
addHandler: vi.fn((handler: (...args: unknown[]) => Promise<boolean | void>) => {
|
|
||||||
centralRemoteHandlers.push(handler);
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
onReplicationFailed: { addHandler: vi.fn() },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const generalBeforeReplicate = vi.fn(async () => true);
|
|
||||||
const module = {
|
|
||||||
_unresolvedErrorManager: {
|
|
||||||
showError: vi.fn(),
|
|
||||||
clearError: vi.fn(),
|
|
||||||
},
|
|
||||||
_onBeforeReplicatorPublication: vi.fn(),
|
|
||||||
_everyOnDatabaseInitialized: vi.fn(),
|
|
||||||
_everyOnloadAfterLoadSettings: vi.fn(),
|
|
||||||
_parseReplicationResult: vi.fn(),
|
|
||||||
_everyBeforeReplicate: generalBeforeReplicate,
|
|
||||||
onReplicationFailed: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
ModuleReplicator.prototype.onBindFunction.call(module, {} as never, services as never);
|
|
||||||
const online = handlers.get(10);
|
|
||||||
const securitySeed = centralRemoteHandlers[0];
|
|
||||||
const general = handlers.get(100);
|
|
||||||
expect(online).toBeDefined();
|
|
||||||
expect(securitySeed).toBeDefined();
|
|
||||||
expect(general).toBeDefined();
|
|
||||||
|
|
||||||
await expect(online!(false)).resolves.toBe(true);
|
|
||||||
await expect(general!(false)).resolves.toBe(true);
|
|
||||||
|
|
||||||
expect(generalBeforeReplicate).toHaveBeenCalledOnce();
|
|
||||||
expect(createRemoteResource).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await expect(securitySeed!(false)).resolves.toBe(true);
|
|
||||||
expect(createRemoteResource).toHaveBeenCalledOnce();
|
|
||||||
expect(read).toHaveBeenCalledOnce();
|
|
||||||
expect(dispose).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reprocesses stored documents when the normal-file target filters change", async () => {
|
|
||||||
eventHub.offAll();
|
|
||||||
const settings = {
|
|
||||||
handleFilenameCaseSensitive: false,
|
|
||||||
ignoreFiles: ".gitignore",
|
|
||||||
maxMTimeForReflectEvents: 0,
|
|
||||||
syncOnlyRegEx: "^E2E/allowed/.*",
|
|
||||||
syncIgnoreRegEx: "",
|
|
||||||
syncInternalFiles: false,
|
|
||||||
syncMaxSizeInMB: 0,
|
|
||||||
suspendParseReplicationResult: false,
|
|
||||||
useIgnoreFiles: false,
|
|
||||||
} as ObsidianLiveSyncSettings;
|
|
||||||
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() },
|
|
||||||
isSuspended: vi.fn(() => false),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const core = {
|
|
||||||
_services: services,
|
|
||||||
services,
|
|
||||||
settings,
|
|
||||||
} as any;
|
|
||||||
const module = new ModuleReplicator(core);
|
|
||||||
const reprocessStoredDocuments = vi.fn(async () => 1);
|
|
||||||
Object.assign(module.processor, { reprocessStoredDocuments });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await (module as any)._everyOnloadAfterLoadSettings();
|
|
||||||
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings });
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(reprocessStoredDocuments).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
Object.assign(settings, { syncOnlyRegEx: "" });
|
|
||||||
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings });
|
|
||||||
await vi.waitFor(() => expect(reprocessStoredDocuments).toHaveBeenCalledOnce());
|
|
||||||
|
|
||||||
settings.syncMaxSizeInMB = 10;
|
|
||||||
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings });
|
|
||||||
await vi.waitFor(() => expect(reprocessStoredDocuments).toHaveBeenCalledTimes(2));
|
|
||||||
} finally {
|
|
||||||
eventHub.offAll();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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: 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: {
|
|
||||||
addLog: vi.fn(),
|
|
||||||
addCommand: vi.fn(),
|
|
||||||
registerWindow: vi.fn(),
|
|
||||||
addRibbonIcon: vi.fn(),
|
|
||||||
registerProtocolHandler: vi.fn(),
|
|
||||||
},
|
|
||||||
appLifecycle: {
|
|
||||||
getUnresolvedMessages: { addHandler: vi.fn() },
|
|
||||||
},
|
|
||||||
replicator: {
|
|
||||||
getActiveReplicator: vi.fn(() => replacementReplicator),
|
|
||||||
runWithActiveReplicatorContext: vi.fn(async (task: (context: unknown) => unknown) =>
|
|
||||||
task(replacementContext)
|
|
||||||
),
|
|
||||||
},
|
|
||||||
tweakValue: { askResolvingMismatched },
|
|
||||||
};
|
|
||||||
const core = {
|
|
||||||
_services: services,
|
|
||||||
services,
|
|
||||||
settings: {},
|
|
||||||
} as any;
|
|
||||||
const module = new ModuleReplicator(core);
|
|
||||||
|
|
||||||
await (module as any).onReplicationFailed({
|
|
||||||
context,
|
|
||||||
setting: {},
|
|
||||||
outcome,
|
|
||||||
showMessage: false,
|
|
||||||
interaction: NO_INTERACTION,
|
|
||||||
});
|
|
||||||
expect(askResolvingMismatched).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
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({
|
|
||||||
context,
|
|
||||||
setting: {},
|
|
||||||
outcome,
|
|
||||||
showMessage: true,
|
|
||||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
|
||||||
});
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", () => {
|
|
||||||
it("keeps its finite replication and balancing work inside the shared activity boundary", async () => {
|
|
||||||
const activityFinished = vi.fn();
|
|
||||||
const runBoundedRemoteActivity = vi.fn(async (task: () => unknown) => {
|
|
||||||
try {
|
|
||||||
return await task();
|
|
||||||
} finally {
|
|
||||||
activityFinished();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
|
||||||
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, 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: {
|
|
||||||
addLog: vi.fn(),
|
|
||||||
addCommand: vi.fn(),
|
|
||||||
registerWindow: vi.fn(),
|
|
||||||
addRibbonIcon: vi.fn(),
|
|
||||||
registerProtocolHandler: vi.fn(),
|
|
||||||
isMobile: vi.fn(() => false),
|
|
||||||
},
|
|
||||||
setting: { saveSettingData: vi.fn(async () => undefined) },
|
|
||||||
appLifecycle: {
|
|
||||||
getUnresolvedMessages: { addHandler: vi.fn() },
|
|
||||||
},
|
|
||||||
replicator: {
|
|
||||||
getActiveReplicator: vi.fn(() => activeReplicator),
|
|
||||||
runBoundedRemoteActivity,
|
|
||||||
runFiniteReplicationActivity,
|
|
||||||
runWithActiveReplicatorContext,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const localDatabase = {
|
|
||||||
localDatabase: {},
|
|
||||||
clearCaches: vi.fn(),
|
|
||||||
};
|
|
||||||
const core = {
|
|
||||||
_services: services,
|
|
||||||
services,
|
|
||||||
settings: {},
|
|
||||||
localDatabase,
|
|
||||||
confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") },
|
|
||||||
} as any;
|
|
||||||
const module = new ModuleReplicator(core);
|
|
||||||
|
|
||||||
await module.cleaned(true, {} as ObsidianLiveSyncSettings, expectedContext as never);
|
|
||||||
|
|
||||||
expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), {
|
|
||||||
label: "database-cleanup",
|
|
||||||
});
|
|
||||||
expect(runFiniteReplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
|
|
||||||
label: "replication",
|
|
||||||
});
|
|
||||||
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]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+44
-34
@@ -6,8 +6,8 @@ import {
|
|||||||
type EntryLeaf,
|
type EntryLeaf,
|
||||||
type LoadedEntry,
|
type LoadedEntry,
|
||||||
type MetaEntry,
|
type MetaEntry,
|
||||||
|
type ObsidianLiveSyncSettings,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import type { ModuleReplicator } from "./ModuleReplicator";
|
|
||||||
import { isChunk } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
import { isChunk } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
||||||
import {
|
import {
|
||||||
LOG_LEVEL_DEBUG,
|
LOG_LEVEL_DEBUG,
|
||||||
@@ -28,12 +28,34 @@ import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheel
|
|||||||
|
|
||||||
const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot";
|
const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot";
|
||||||
const REPROCESS_BATCH_SIZE = 100;
|
const REPROCESS_BATCH_SIZE = 100;
|
||||||
type LocalApplicationActivityOwner = {
|
type ReplicateResultProcessorSettings = Pick<
|
||||||
runBoundedLocalApplicationActivity<T>(
|
ObsidianLiveSyncSettings,
|
||||||
|
"maxMTimeForReflectEvents" | "suspendParseReplicationResult"
|
||||||
|
>;
|
||||||
|
type ReplicateResultProcessorServices = Pick<
|
||||||
|
LiveSyncBaseCore["services"],
|
||||||
|
"appLifecycle" | "path" | "replication" | "vault"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrow collaborators for applying replicated documents.
|
||||||
|
*
|
||||||
|
* `requestActiveReplicatorRetirement` starts the owner transition without
|
||||||
|
* awaiting it. Result application can still be running inside work admitted by
|
||||||
|
* that owner, so awaiting retirement here could make each side wait for the
|
||||||
|
* other to finish.
|
||||||
|
*/
|
||||||
|
interface ReplicateResultProcessorContext {
|
||||||
|
readonly currentSettings: () => ReplicateResultProcessorSettings;
|
||||||
|
readonly keyValueDB: LiveSyncBaseCore["kvDB"];
|
||||||
|
readonly localDatabase: LiveSyncBaseCore["localDatabase"];
|
||||||
|
readonly requestActiveReplicatorRetirement: () => void;
|
||||||
|
readonly runLocalApplicationActivity: <T>(
|
||||||
task: () => T | PromiseLike<T>,
|
task: () => T | PromiseLike<T>,
|
||||||
options?: { label?: string }
|
options?: { label?: string }
|
||||||
): Promise<T>;
|
) => Promise<T>;
|
||||||
};
|
readonly services: ReplicateResultProcessorServices;
|
||||||
|
}
|
||||||
type ReplicateResultProcessorState = {
|
type ReplicateResultProcessorState = {
|
||||||
queued: PouchDB.Core.ExistingDocument<EntryDoc>[];
|
queued: PouchDB.Core.ExistingDocument<EntryDoc>[];
|
||||||
processing: PouchDB.Core.ExistingDocument<EntryDoc>[];
|
processing: PouchDB.Core.ExistingDocument<EntryDoc>[];
|
||||||
@@ -52,20 +74,13 @@ export class ReplicateResultProcessor {
|
|||||||
private logError(e: unknown) {
|
private logError(e: unknown) {
|
||||||
Logger(e, LOG_LEVEL_VERBOSE);
|
Logger(e, LOG_LEVEL_VERBOSE);
|
||||||
}
|
}
|
||||||
private replicator: ModuleReplicator;
|
constructor(private readonly context: ReplicateResultProcessorContext) {}
|
||||||
|
|
||||||
constructor(replicator: ModuleReplicator) {
|
private get localDatabase() {
|
||||||
this.replicator = replicator;
|
return this.context.localDatabase;
|
||||||
}
|
}
|
||||||
|
private get services() {
|
||||||
get localDatabase() {
|
return this.context.services;
|
||||||
return this.replicator.core.localDatabase;
|
|
||||||
}
|
|
||||||
get services() {
|
|
||||||
return this.replicator.core.services;
|
|
||||||
}
|
|
||||||
get core(): LiveSyncBaseCore {
|
|
||||||
return this.replicator.core;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getPath(entry: AnyEntry): string {
|
getPath(entry: AnyEntry): string {
|
||||||
@@ -89,9 +104,9 @@ export class ReplicateResultProcessor {
|
|||||||
public get isSuspended() {
|
public get isSuspended() {
|
||||||
return (
|
return (
|
||||||
this._suspended ||
|
this._suspended ||
|
||||||
!this.core.services.appLifecycle.isReady ||
|
!this.services.appLifecycle.isReady ||
|
||||||
this.replicator.settings.suspendParseReplicationResult ||
|
this.context.currentSettings().suspendParseReplicationResult ||
|
||||||
this.core.services.appLifecycle.isSuspended()
|
this.services.appLifecycle.isSuspended()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +119,7 @@ export class ReplicateResultProcessor {
|
|||||||
queued: this._queuedChanges.slice(),
|
queued: this._queuedChanges.slice(),
|
||||||
processing: this._processingChanges.slice(),
|
processing: this._processingChanges.slice(),
|
||||||
} satisfies ReplicateResultProcessorState;
|
} satisfies ReplicateResultProcessorState;
|
||||||
await this.core.kvDB.set(KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT, snapshot);
|
await this.context.keyValueDB.set(KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT, snapshot);
|
||||||
this.log(
|
this.log(
|
||||||
`Snapshot taken. Queued: ${snapshot.queued.length}, Processing: ${snapshot.processing.length}`,
|
`Snapshot taken. Queued: ${snapshot.queued.length}, Processing: ${snapshot.processing.length}`,
|
||||||
LOG_LEVEL_DEBUG
|
LOG_LEVEL_DEBUG
|
||||||
@@ -126,7 +141,7 @@ export class ReplicateResultProcessor {
|
|||||||
* Restore from snapshot.
|
* Restore from snapshot.
|
||||||
*/
|
*/
|
||||||
public async restoreFromSnapshot() {
|
public async restoreFromSnapshot() {
|
||||||
const snapshot = await this.core.kvDB.get<ReplicateResultProcessorState>(
|
const snapshot = await this.context.keyValueDB.get<ReplicateResultProcessorState>(
|
||||||
KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT
|
KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT
|
||||||
);
|
);
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
@@ -231,8 +246,8 @@ export class ReplicateResultProcessor {
|
|||||||
if (change.type == "versioninfo") {
|
if (change.type == "versioninfo") {
|
||||||
this.log(`Version info document received: ${change._id}`, LOG_LEVEL_VERBOSE);
|
this.log(`Version info document received: ${change._id}`, LOG_LEVEL_VERBOSE);
|
||||||
if (change.version > VER) {
|
if (change.version > VER) {
|
||||||
// Incompatible version, stop replication.
|
// Fence and retire the active publication through its owner.
|
||||||
this.core.replicator.closeReplication();
|
this.context.requestActiveReplicatorRetirement();
|
||||||
this.log(
|
this.log(
|
||||||
`Remote database updated to incompatible version. update your Self-hosted LiveSync plugin.`,
|
`Remote database updated to incompatible version. update your Self-hosted LiveSync plugin.`,
|
||||||
LOG_LEVEL_NOTICE
|
LOG_LEVEL_NOTICE
|
||||||
@@ -277,15 +292,10 @@ export class ReplicateResultProcessor {
|
|||||||
|
|
||||||
const activityDone = promiseWithResolvers<void>();
|
const activityDone = promiseWithResolvers<void>();
|
||||||
this._processingActivityDone = activityDone;
|
this._processingActivityDone = activityDone;
|
||||||
const activityOwner = this.services.replicator as typeof this.services.replicator &
|
this._processingActivity = this.context
|
||||||
Partial<LocalApplicationActivityOwner>;
|
.runLocalApplicationActivity(() => activityDone.promise, {
|
||||||
this._processingActivity = (
|
label: "replicated-document-application",
|
||||||
activityOwner.runBoundedLocalApplicationActivity
|
})
|
||||||
? activityOwner.runBoundedLocalApplicationActivity(() => activityDone.promise, {
|
|
||||||
label: "replicated-document-application",
|
|
||||||
})
|
|
||||||
: activityDone.promise
|
|
||||||
)
|
|
||||||
.catch((error) => this.logError(error))
|
.catch((error) => this.logError(error))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (this._processingActivityDone === activityDone) this._processingActivityDone = undefined;
|
if (this._processingActivityDone === activityDone) this._processingActivityDone = undefined;
|
||||||
@@ -392,7 +402,7 @@ export class ReplicateResultProcessor {
|
|||||||
try {
|
try {
|
||||||
if (isAnyNote(change)) {
|
if (isAnyNote(change)) {
|
||||||
const docMtime = change.mtime ?? 0;
|
const docMtime = change.mtime ?? 0;
|
||||||
const maxMTime = this.replicator.settings.maxMTimeForReflectEvents;
|
const maxMTime = this.context.currentSettings().maxMTimeForReflectEvents;
|
||||||
if (maxMTime > 0 && docMtime > maxMTime) {
|
if (maxMTime > 0 && docMtime > maxMTime) {
|
||||||
const docPath = this.getPath(change);
|
const docPath = this.getPath(change);
|
||||||
this.log(
|
this.log(
|
||||||
+32
-7
@@ -1,7 +1,7 @@
|
|||||||
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||||
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { VER, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
|
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
|
||||||
|
|
||||||
function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
|
function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
|
||||||
@@ -28,6 +28,7 @@ function setup(options: SetupOptions = {}) {
|
|||||||
const processSynchroniseResult = vi.fn(options.processSynchroniseResult ?? (async () => undefined));
|
const processSynchroniseResult = vi.fn(options.processSynchroniseResult ?? (async () => undefined));
|
||||||
const setSnapshot = vi.fn(options.setSnapshot ?? (async () => undefined));
|
const setSnapshot = vi.fn(options.setSnapshot ?? (async () => undefined));
|
||||||
const runBoundedLocalApplicationActivity = vi.fn(async (task: () => Promise<void>) => await task());
|
const runBoundedLocalApplicationActivity = vi.fn(async (task: () => Promise<void>) => await task());
|
||||||
|
const onCloseActiveReplication = vi.fn(async () => true);
|
||||||
const core = {
|
const core = {
|
||||||
services: {
|
services: {
|
||||||
appLifecycle: { isReady: true, isSuspended: () => false },
|
appLifecycle: { isReady: true, isSuspended: () => false },
|
||||||
@@ -40,7 +41,7 @@ function setup(options: SetupOptions = {}) {
|
|||||||
processOptionalSynchroniseResult: vi.fn(async () => false),
|
processOptionalSynchroniseResult: vi.fn(async () => false),
|
||||||
processSynchroniseResult,
|
processSynchroniseResult,
|
||||||
},
|
},
|
||||||
replicator: { runBoundedLocalApplicationActivity },
|
replicator: { onCloseActiveReplication, runBoundedLocalApplicationActivity },
|
||||||
vault: {
|
vault: {
|
||||||
isTargetFile: vi.fn(async () => true),
|
isTargetFile: vi.fn(async () => true),
|
||||||
isFileSizeTooLarge: vi.fn(() => false),
|
isFileSizeTooLarge: vi.fn(() => false),
|
||||||
@@ -52,16 +53,40 @@ function setup(options: SetupOptions = {}) {
|
|||||||
getRaw: vi.fn(async (id: string) => ({ _id: id, _rev: "1-test" })),
|
getRaw: vi.fn(async (id: string) => ({ _id: id, _rev: "1-test" })),
|
||||||
getDBEntryFromMeta: vi.fn(async (entry: object) => ({ ...entry, data: "x" })),
|
getDBEntryFromMeta: vi.fn(async (entry: object) => ({ ...entry, data: "x" })),
|
||||||
},
|
},
|
||||||
replicator: { closeReplication: vi.fn() },
|
|
||||||
};
|
};
|
||||||
const processor = new ReplicateResultProcessor({
|
const processor = new ReplicateResultProcessor({
|
||||||
core,
|
currentSettings: () => ({ maxMTimeForReflectEvents: 0, suspendParseReplicationResult: false }),
|
||||||
settings: { maxMTimeForReflectEvents: 0, suspendParseReplicationResult: false },
|
keyValueDB: core.kvDB,
|
||||||
|
localDatabase: core.localDatabase,
|
||||||
|
requestActiveReplicatorRetirement: () => {
|
||||||
|
void onCloseActiveReplication();
|
||||||
|
},
|
||||||
|
runLocalApplicationActivity: runBoundedLocalApplicationActivity,
|
||||||
|
services: core.services,
|
||||||
} as never);
|
} as never);
|
||||||
return { processor, processSynchroniseResult, runBoundedLocalApplicationActivity };
|
return {
|
||||||
|
onCloseActiveReplication,
|
||||||
|
processor,
|
||||||
|
processSynchroniseResult,
|
||||||
|
runBoundedLocalApplicationActivity,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ReplicateResultProcessor", () => {
|
describe("ReplicateResultProcessor", () => {
|
||||||
|
it("retires active ownership when a newer remote version is observed", async () => {
|
||||||
|
const { onCloseActiveReplication, processor } = setup();
|
||||||
|
const versionInfo = {
|
||||||
|
_id: "versioninfo",
|
||||||
|
_rev: "1-test",
|
||||||
|
type: "versioninfo",
|
||||||
|
version: VER + 1,
|
||||||
|
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||||
|
|
||||||
|
processor.enqueueAll([versionInfo]);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(onCloseActiveReplication).toHaveBeenCalledOnce());
|
||||||
|
});
|
||||||
|
|
||||||
it("scans normal-file metadata without loading chunk documents and requeues it", async () => {
|
it("scans normal-file metadata without loading chunk documents and requeues it", async () => {
|
||||||
const documents = [
|
const documents = [
|
||||||
{ _id: "first", _rev: "1-a", type: "plain", path: "first.md" },
|
{ _id: "first", _rev: "1-a", type: "plain", path: "first.md" },
|
||||||
@@ -71,7 +96,7 @@ describe("ReplicateResultProcessor", () => {
|
|||||||
yield* documents;
|
yield* documents;
|
||||||
});
|
});
|
||||||
const processor = new ReplicateResultProcessor({
|
const processor = new ReplicateResultProcessor({
|
||||||
core: { localDatabase: { findAllNormalDocs } },
|
localDatabase: { findAllNormalDocs },
|
||||||
} as never);
|
} as never);
|
||||||
const enqueueAll = vi.spyOn(processor, "enqueueAll").mockImplementation(() => undefined);
|
const enqueueAll = vi.spyOn(processor, "enqueueAll").mockImplementation(() => undefined);
|
||||||
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import { fireAndForget } from "octagonal-wheels/promises";
|
||||||
|
import { scheduleTask } from "octagonal-wheels/concurrency/task";
|
||||||
|
import { EVENT_FILE_SAVED, EVENT_SETTING_SAVED, eventHub } from "@/common/events";
|
||||||
|
|
||||||
|
type ReflectionFilterSettings = Pick<
|
||||||
|
ObsidianLiveSyncSettings,
|
||||||
|
| "handleFilenameCaseSensitive"
|
||||||
|
| "ignoreFiles"
|
||||||
|
| "maxMTimeForReflectEvents"
|
||||||
|
| "syncIgnoreRegEx"
|
||||||
|
| "syncInternalFiles"
|
||||||
|
| "syncMaxSizeInMB"
|
||||||
|
| "syncOnlyRegEx"
|
||||||
|
| "useIgnoreFiles"
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface AutomaticReplicationTriggerContext {
|
||||||
|
readonly currentSettings: () => ObsidianLiveSyncSettings;
|
||||||
|
readonly isSuspended: () => boolean;
|
||||||
|
readonly replicateDatabaseEvent: () => Promise<unknown>;
|
||||||
|
readonly reprocessStoredDocuments: () => Promise<number>;
|
||||||
|
readonly resumeResultApplication: () => void;
|
||||||
|
readonly suspendResultApplication: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalFileReflectionFilterSignature(settings: ReflectionFilterSettings): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
handleFilenameCaseSensitive: settings.handleFilenameCaseSensitive ?? false,
|
||||||
|
ignoreFiles: settings.ignoreFiles ?? "",
|
||||||
|
maxMTimeForReflectEvents: settings.maxMTimeForReflectEvents ?? 0,
|
||||||
|
syncIgnoreRegEx: settings.syncIgnoreRegEx ?? "",
|
||||||
|
syncInternalFiles: settings.syncInternalFiles ?? false,
|
||||||
|
syncMaxSizeInMB: settings.syncMaxSizeInMB ?? 0,
|
||||||
|
syncOnlyRegEx: settings.syncOnlyRegEx ?? "",
|
||||||
|
useIgnoreFiles: settings.useIgnoreFiles ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the settings-loaded handler which installs automatic replication and
|
||||||
|
* result-application reactions. The returned closure owns the previous filter
|
||||||
|
* signature; it is private composition state rather than a shared service.
|
||||||
|
*/
|
||||||
|
export function createAutomaticReplicationTriggers(context: AutomaticReplicationTriggerContext) {
|
||||||
|
let reflectionFilterSignature: string | undefined;
|
||||||
|
|
||||||
|
return function initialiseAutomaticReplicationTriggers(): Promise<boolean> {
|
||||||
|
reflectionFilterSignature = normalFileReflectionFilterSignature(context.currentSettings());
|
||||||
|
eventHub.onEvent(EVENT_FILE_SAVED, () => {
|
||||||
|
if (context.currentSettings().syncOnSave && !context.isSuspended()) {
|
||||||
|
scheduleTask("perform-replicate-after-save", 250, () => context.replicateDatabaseEvent());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
eventHub.onEvent(EVENT_SETTING_SAVED, (settings) => {
|
||||||
|
const previousReflectionFilter = reflectionFilterSignature;
|
||||||
|
const nextReflectionFilter = normalFileReflectionFilterSignature(settings);
|
||||||
|
reflectionFilterSignature = nextReflectionFilter;
|
||||||
|
if (settings.suspendParseReplicationResult) {
|
||||||
|
context.suspendResultApplication();
|
||||||
|
} else {
|
||||||
|
context.resumeResultApplication();
|
||||||
|
}
|
||||||
|
if (previousReflectionFilter !== undefined && previousReflectionFilter !== nextReflectionFilter) {
|
||||||
|
fireAndForget(() => context.reprocessStoredDocuments());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.resolve(true);
|
||||||
|
};
|
||||||
|
}
|
||||||
+56
-20
@@ -4,9 +4,10 @@ import {
|
|||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
REMOTE_P2P,
|
REMOTE_P2P,
|
||||||
type FilePathWithPrefix,
|
type FilePathWithPrefix,
|
||||||
|
type ObsidianLiveSyncSettings,
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||||
import { EVENT_FILE_SAVED, eventHub } from "@/common/events";
|
import { EVENT_FILE_SAVED, EVENT_SETTING_SAVED, eventHub } from "@/common/events";
|
||||||
|
|
||||||
const taskMocks = vi.hoisted(() => ({
|
const taskMocks = vi.hoisted(() => ({
|
||||||
scheduleTask: vi.fn((_key: string, _delay: number, task: () => unknown) => task()),
|
scheduleTask: vi.fn((_key: string, _delay: number, task: () => unknown) => task()),
|
||||||
@@ -14,15 +15,15 @@ const taskMocks = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock("octagonal-wheels/concurrency/task", () => taskMocks);
|
vi.mock("octagonal-wheels/concurrency/task", () => taskMocks);
|
||||||
|
|
||||||
import { ModuleConflictResolver } from "../coreFeatures/ModuleConflictResolver";
|
import { ModuleConflictResolver } from "@/modules/coreFeatures/ModuleConflictResolver";
|
||||||
import { ModuleObsidianEvents } from "../essentialObsidian/ModuleObsidianEvents";
|
import { ModuleObsidianEvents } from "@/modules/essentialObsidian/ModuleObsidianEvents";
|
||||||
import {
|
import {
|
||||||
createReplicationSchedulingContext,
|
createReplicationSchedulingContext,
|
||||||
realiseReplicationScheduling,
|
realiseReplicationScheduling,
|
||||||
resumeReplicationScheduling,
|
resumeReplicationScheduling,
|
||||||
runPeriodicReplication,
|
runPeriodicReplication,
|
||||||
} from "@/serviceFeatures/replicationScheduling";
|
} from "@/serviceFeatures/replicationScheduling";
|
||||||
import { ModuleReplicator } from "./ModuleReplicator";
|
import { createAutomaticReplicationTriggers } from "./automaticTriggers";
|
||||||
|
|
||||||
function createApi() {
|
function createApi() {
|
||||||
return {
|
return {
|
||||||
@@ -124,24 +125,22 @@ describe("automatic replication triggers while P2P is active", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps database-save synchronisation on the event replication boundary", async () => {
|
it("keeps database-save synchronisation on the event replication boundary", async () => {
|
||||||
const replicateUnattendedByEvent = vi.fn(async () => ({ status: "completed" as const }));
|
const replicateUnattendedByEvent = vi.fn(async (_request: unknown) => ({ status: "completed" as const }));
|
||||||
const settings = p2pSettings({ syncOnSave: true });
|
const settings = p2pSettings({ syncOnSave: true });
|
||||||
const services = {
|
const initialise = createAutomaticReplicationTriggers({
|
||||||
appLifecycle: { isSuspended: vi.fn(() => false) },
|
currentSettings: () => settings,
|
||||||
replication: { replicateUnattendedByEvent },
|
isSuspended: vi.fn(() => false),
|
||||||
};
|
replicateDatabaseEvent: () =>
|
||||||
const module = {
|
replicateUnattendedByEvent({
|
||||||
core: { services, settings },
|
trigger: "database-event",
|
||||||
services,
|
interaction: NO_INTERACTION,
|
||||||
settings,
|
}),
|
||||||
getNormalFileReflectionFilterSignature: (
|
reprocessStoredDocuments: vi.fn(async () => 0),
|
||||||
ModuleReplicator.prototype as unknown as {
|
resumeResultApplication: vi.fn(),
|
||||||
getNormalFileReflectionFilterSignature: (value: typeof settings) => string;
|
suspendResultApplication: vi.fn(),
|
||||||
}
|
});
|
||||||
).getNormalFileReflectionFilterSignature,
|
|
||||||
};
|
|
||||||
|
|
||||||
await (ModuleReplicator.prototype as any)._everyOnloadAfterLoadSettings.call(module);
|
await initialise();
|
||||||
eventHub.emitEvent(EVENT_FILE_SAVED);
|
eventHub.emitEvent(EVENT_FILE_SAVED);
|
||||||
|
|
||||||
await vi.waitFor(() => expect(replicateUnattendedByEvent).toHaveBeenCalledOnce());
|
await vi.waitFor(() => expect(replicateUnattendedByEvent).toHaveBeenCalledOnce());
|
||||||
@@ -151,6 +150,43 @@ describe("automatic replication triggers while P2P is active", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reprocesses stored documents when normal-file target filters change", async () => {
|
||||||
|
const settings = {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
ignoreFiles: ".gitignore",
|
||||||
|
syncOnlyRegEx: "^E2E/allowed/.*",
|
||||||
|
} as ObsidianLiveSyncSettings;
|
||||||
|
const reprocessStoredDocuments = vi.fn(async () => 1);
|
||||||
|
const resumeResultApplication = vi.fn();
|
||||||
|
const suspendResultApplication = vi.fn();
|
||||||
|
const initialise = createAutomaticReplicationTriggers({
|
||||||
|
currentSettings: () => settings,
|
||||||
|
isSuspended: vi.fn(() => false),
|
||||||
|
replicateDatabaseEvent: vi.fn(async () => undefined),
|
||||||
|
reprocessStoredDocuments,
|
||||||
|
resumeResultApplication,
|
||||||
|
suspendResultApplication,
|
||||||
|
});
|
||||||
|
|
||||||
|
await initialise();
|
||||||
|
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings });
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(reprocessStoredDocuments).not.toHaveBeenCalled();
|
||||||
|
expect(resumeResultApplication).toHaveBeenCalledOnce();
|
||||||
|
expect(suspendResultApplication).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings, suspendParseReplicationResult: true });
|
||||||
|
expect(suspendResultApplication).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
Object.assign(settings, { syncOnlyRegEx: "" });
|
||||||
|
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings });
|
||||||
|
await vi.waitFor(() => expect(reprocessStoredDocuments).toHaveBeenCalledOnce());
|
||||||
|
|
||||||
|
settings.syncMaxSizeInMB = 10;
|
||||||
|
eventHub.emitEvent(EVENT_SETTING_SAVED, { ...settings });
|
||||||
|
await vi.waitFor(() => expect(reprocessStoredDocuments).toHaveBeenCalledTimes(2));
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps editor-save synchronisation on the event replication boundary", async () => {
|
it("keeps editor-save synchronisation on the event replication boundary", async () => {
|
||||||
const { module, replicateUnattendedByEvent, save, saveCommand } = createObsidianEventHarness({
|
const { module, replicateUnattendedByEvent, save, saveCommand } = createObsidianEventHarness({
|
||||||
syncOnEditorSave: true,
|
syncOnEditorSave: true,
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
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";
|
||||||
|
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||||
|
import {
|
||||||
|
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||||
|
type ReplicationFailureRequest,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/replication";
|
||||||
|
import { $msg } from "@/common/translation";
|
||||||
|
import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings";
|
||||||
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||||
|
|
||||||
|
type CentralCompatibilityRecoveryServices = Pick<
|
||||||
|
LiveSyncBaseCore["services"],
|
||||||
|
"API" | "appLifecycle" | "replicator" | "tweakValue"
|
||||||
|
>;
|
||||||
|
|
||||||
|
interface CentralCompatibilityRecoveryContext {
|
||||||
|
readonly confirm: LiveSyncBaseCore["confirm"];
|
||||||
|
readonly localDatabase: LiveSyncBaseCore["localDatabase"];
|
||||||
|
readonly rebuilder: LiveSyncBaseCore["rebuilder"];
|
||||||
|
readonly services: CentralCompatibilityRecoveryServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose central compatibility recovery around the exact failed publication.
|
||||||
|
* Remote mutations re-admit that publication and become no-ops after a
|
||||||
|
* replacement; the failure result is never re-read from the current instance.
|
||||||
|
*/
|
||||||
|
export function createCentralCompatibilityRecovery(context: CentralCompatibilityRecoveryContext) {
|
||||||
|
async function reconcileCleanedRemote(
|
||||||
|
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(context.localDatabase.localDatabase, true);
|
||||||
|
const message = `The remote database has been cleaned up.
|
||||||
|
To synchronize, this device must be also cleaned up. ${count} chunk(s) will be erased from this device.
|
||||||
|
However, If there are many chunks to be deleted, maybe fetching again is faster.
|
||||||
|
We will lose the history of this device if we fetch the remote database again.
|
||||||
|
Even if you choose to clean up, you will see this option again if you exit Obsidian and then synchronise again.`;
|
||||||
|
const CHOICE_FETCH = "Fetch again";
|
||||||
|
const CHOICE_CLEAN = "Cleanup";
|
||||||
|
const CHOICE_DISMISS = "Dismiss";
|
||||||
|
const selected = await context.confirm.confirmWithMessage(
|
||||||
|
"Cleaned",
|
||||||
|
message,
|
||||||
|
[CHOICE_FETCH, CHOICE_CLEAN, CHOICE_DISMISS],
|
||||||
|
CHOICE_DISMISS,
|
||||||
|
30
|
||||||
|
);
|
||||||
|
if (selected == CHOICE_FETCH) {
|
||||||
|
await context.rebuilder.$performRebuildDB("localOnly");
|
||||||
|
}
|
||||||
|
if (selected != CHOICE_CLEAN) return;
|
||||||
|
|
||||||
|
await context.services.replicator.runBoundedRemoteActivity(
|
||||||
|
() =>
|
||||||
|
context.services.replicator.runWithActiveReplicatorContext(async (activeContext) => {
|
||||||
|
if (activeContext !== expectedContext) return;
|
||||||
|
const replicator = activeContext.replicator;
|
||||||
|
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
|
||||||
|
const remoteDatabase = await replicator.connectRemoteCouchDBWithSetting(
|
||||||
|
setting,
|
||||||
|
context.services.API.isMobile(),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
if (typeof remoteDatabase == "string") {
|
||||||
|
Logger(remoteDatabase, LOG_LEVEL_NOTICE);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await purgeUnreferencedChunks(context.localDatabase.localDatabase, false);
|
||||||
|
context.localDatabase.clearCaches();
|
||||||
|
const replicated = await context.services.replicator.runFiniteReplicationActivity(
|
||||||
|
() => replicator.openOneShotReplication(setting, showMessage, false, "sync", true),
|
||||||
|
{ label: "replication" }
|
||||||
|
);
|
||||||
|
if (replicated) {
|
||||||
|
await balanceChunkPurgedDBs(context.localDatabase.localDatabase, remoteDatabase.db);
|
||||||
|
await purgeUnreferencedChunks(context.localDatabase.localDatabase, false);
|
||||||
|
context.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 remoteDatabase.close();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ label: "database-cleanup" }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReplicationFailure(request: ReplicationFailureRequest): Promise<boolean> {
|
||||||
|
const { context: failedContext, interaction, outcome, setting, showMessage } = request;
|
||||||
|
if (!showMessage) {
|
||||||
|
// Automatic requests may report the failure, but must not enter
|
||||||
|
// tweak, lock, fetch, unlock, or cleanup dialogues.
|
||||||
|
Logger("Replication failed on an unattended path.", LOG_LEVEL_INFO);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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 context.services.tweakValue.askResolvingMismatched(
|
||||||
|
recovery.preferredTweakValue,
|
||||||
|
async (effectiveSetting) => {
|
||||||
|
let updated = false;
|
||||||
|
await context.services.replicator.runWithActiveReplicatorContext(async (activeContext) => {
|
||||||
|
if (activeContext !== failedContext) 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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
recovery.reason !== CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED &&
|
||||||
|
recovery.reason !== CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED &&
|
||||||
|
usesLegacyIndexedDBAdapter(setting)
|
||||||
|
) {
|
||||||
|
await reconcileCleanedRemote(showMessage, setting, failedContext);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = $msg("Replicator.Dialogue.Locked.Message");
|
||||||
|
const CHOICE_FETCH = $msg("Replicator.Dialogue.Locked.Action.Fetch");
|
||||||
|
const CHOICE_DISMISS = $msg("Replicator.Dialogue.Locked.Action.Dismiss");
|
||||||
|
const CHOICE_UNLOCK = $msg("Replicator.Dialogue.Locked.Action.Unlock");
|
||||||
|
const selected = await context.confirm.askSelectStringDialogue(
|
||||||
|
message,
|
||||||
|
[CHOICE_FETCH, CHOICE_UNLOCK, CHOICE_DISMISS],
|
||||||
|
{
|
||||||
|
title: $msg("Replicator.Dialogue.Locked.Title"),
|
||||||
|
defaultAction: CHOICE_DISMISS,
|
||||||
|
timeout: 60,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (selected == CHOICE_FETCH) {
|
||||||
|
Logger($msg("Replicator.Dialogue.Locked.Message.Fetch"), LOG_LEVEL_NOTICE);
|
||||||
|
await context.rebuilder.scheduleFetch();
|
||||||
|
context.services.appLifecycle.scheduleRestart();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (selected != CHOICE_UNLOCK) return false;
|
||||||
|
|
||||||
|
let unlocked = false;
|
||||||
|
await context.services.replicator.runWithActiveReplicatorContext(async (activeContext) => {
|
||||||
|
if (activeContext !== failedContext) 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) {
|
||||||
|
Logger($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({ handleReplicationFailure, reconcileCleanedRemote });
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
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 (_database: unknown, countOnly: boolean) => (countOnly ? 2 : 0)),
|
||||||
|
balanceChunkPurgedDBs: vi.fn(async () => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/chunks", () => chunkMocks);
|
||||||
|
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||||
|
LiveSyncCouchDBReplicator: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||||
|
import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery";
|
||||||
|
|
||||||
|
describe("central compatibility recovery", () => {
|
||||||
|
it("uses the exact failed outcome and permits dialogue only with recovery authority", async () => {
|
||||||
|
const askResolvingMismatched = vi.fn(async (..._arguments: 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: 99 },
|
||||||
|
setPreferredRemoteTweakSettings: replacementSetPreferred,
|
||||||
|
};
|
||||||
|
const failedContext = { 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 recovery = createCentralCompatibilityRecovery({
|
||||||
|
confirm: {},
|
||||||
|
localDatabase: {},
|
||||||
|
rebuilder: {},
|
||||||
|
services: {
|
||||||
|
appLifecycle: {},
|
||||||
|
API: {},
|
||||||
|
replicator: {
|
||||||
|
runWithActiveReplicatorContext: vi.fn(async (task: (context: unknown) => unknown) =>
|
||||||
|
task(replacementContext)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
tweakValue: { askResolvingMismatched },
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await recovery.handleReplicationFailure({
|
||||||
|
context: failedContext,
|
||||||
|
setting: {},
|
||||||
|
outcome,
|
||||||
|
showMessage: false,
|
||||||
|
interaction: NO_INTERACTION,
|
||||||
|
} as never);
|
||||||
|
expect(askResolvingMismatched).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await recovery.handleReplicationFailure({
|
||||||
|
context: failedContext,
|
||||||
|
setting: {},
|
||||||
|
outcome,
|
||||||
|
showMessage: false,
|
||||||
|
interaction: {
|
||||||
|
kind: "permitted",
|
||||||
|
permissions: { ...USER_INITIATED_REPLICATION_AUTHORITY.permissions, failureRecovery: false },
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
expect(askResolvingMismatched).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await recovery.handleReplicationFailure({
|
||||||
|
context: failedContext,
|
||||||
|
setting: {},
|
||||||
|
outcome,
|
||||||
|
showMessage: true,
|
||||||
|
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
|
} as never);
|
||||||
|
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 })).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 failedContext = { 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 recovery = createCentralCompatibilityRecovery({
|
||||||
|
confirm: {},
|
||||||
|
localDatabase: {},
|
||||||
|
rebuilder: {},
|
||||||
|
services: {
|
||||||
|
appLifecycle: {},
|
||||||
|
API: {},
|
||||||
|
replicator: {
|
||||||
|
runWithActiveReplicatorContext: vi.fn(async (task: (context: unknown) => unknown) =>
|
||||||
|
task(failedContext)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
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 },
|
||||||
|
}),
|
||||||
|
showMessage: true,
|
||||||
|
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
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 recovery = createCentralCompatibilityRecovery({
|
||||||
|
confirm: {
|
||||||
|
askSelectStringDialogue: vi.fn(async (_message: string, choices: string[]) => choices[1]),
|
||||||
|
},
|
||||||
|
localDatabase: {},
|
||||||
|
rebuilder: {},
|
||||||
|
services: {
|
||||||
|
appLifecycle: { scheduleRestart: vi.fn() },
|
||||||
|
API: {},
|
||||||
|
replicator: { runWithActiveReplicatorContext },
|
||||||
|
tweakValue: {},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await recovery.handleReplicationFailure({
|
||||||
|
context: failedContext,
|
||||||
|
setting: {},
|
||||||
|
outcome: replicationFailed(new Error("locked"), {
|
||||||
|
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
|
||||||
|
}),
|
||||||
|
showMessage: true,
|
||||||
|
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(runWithActiveReplicatorContext).toHaveBeenCalledOnce();
|
||||||
|
expect(failedMarkResolved).not.toHaveBeenCalled();
|
||||||
|
expect(replacementMarkResolved).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps cleaned-remote replication and balancing inside the shared activity boundary", async () => {
|
||||||
|
const activityFinished = vi.fn();
|
||||||
|
const runBoundedRemoteActivity = vi.fn(async (task: () => unknown) => {
|
||||||
|
try {
|
||||||
|
return await task();
|
||||||
|
} finally {
|
||||||
|
activityFinished();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
||||||
|
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 never), {
|
||||||
|
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 localDatabase = { localDatabase: {}, clearCaches: vi.fn() };
|
||||||
|
const recovery = createCentralCompatibilityRecovery({
|
||||||
|
confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") },
|
||||||
|
localDatabase,
|
||||||
|
rebuilder: {},
|
||||||
|
services: {
|
||||||
|
appLifecycle: {},
|
||||||
|
API: { isMobile: vi.fn(() => false) },
|
||||||
|
replicator: {
|
||||||
|
runBoundedRemoteActivity,
|
||||||
|
runFiniteReplicationActivity,
|
||||||
|
runWithActiveReplicatorContext,
|
||||||
|
},
|
||||||
|
tweakValue: {},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, expectedContext as never);
|
||||||
|
|
||||||
|
expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||||
|
label: "database-cleanup",
|
||||||
|
});
|
||||||
|
expect(runFiniteReplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||||
|
label: "replication",
|
||||||
|
});
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication";
|
||||||
|
import { clearHandlers } from "@vrtmrz/livesync-commonlib/compat/replication/SyncParamsHandler";
|
||||||
|
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
|
||||||
|
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
|
import { fireAndForget } from "octagonal-wheels/promises";
|
||||||
|
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||||
|
import { createAutomaticReplicationTriggers } from "./automaticTriggers";
|
||||||
|
import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery";
|
||||||
|
import { createOnlineReplicationPreflight, createSecuritySeedPreflight } from "./preflight";
|
||||||
|
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
|
||||||
|
|
||||||
|
type LocalApplicationActivityOwner = {
|
||||||
|
runBoundedLocalApplicationActivity<T>(task: () => T | PromiseLike<T>, options?: { label?: string }): Promise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose result application, automatic triggers, preflight, and central
|
||||||
|
* compatibility recovery around the existing typed Services.
|
||||||
|
*
|
||||||
|
* Registration order is observable for equal-priority handlers. The host must
|
||||||
|
* call this after host serviceFeatures and add-ons are composed, but before
|
||||||
|
* legacy modules are bound. This preserves the former ModuleReplicator
|
||||||
|
* lifecycle-handler order without retaining a public module identity.
|
||||||
|
*/
|
||||||
|
export function useReplicationFeature<TContext extends ServiceContext, TCommands extends IMinimumLiveSyncCommands>(
|
||||||
|
core: LiveSyncBaseCore<TContext, TCommands>
|
||||||
|
): void {
|
||||||
|
const { services } = core;
|
||||||
|
// Obsidian adds an application-activity owner to its ReplicatorService.
|
||||||
|
// Generic hosts retain the former direct-execution fallback.
|
||||||
|
const localApplicationActivityOwner = services.replicator as typeof services.replicator &
|
||||||
|
Partial<LocalApplicationActivityOwner>;
|
||||||
|
const resultProcessor = new ReplicateResultProcessor({
|
||||||
|
currentSettings: () => services.setting.currentSettings(),
|
||||||
|
keyValueDB: services.keyValueDB.kvDB,
|
||||||
|
localDatabase: core.localDatabase,
|
||||||
|
requestActiveReplicatorRetirement: () => {
|
||||||
|
// Do not await a retirement transition from result application: it
|
||||||
|
// may be draining the replication work which delivered this item.
|
||||||
|
fireAndForget(() => services.replicator.onCloseActiveReplication());
|
||||||
|
},
|
||||||
|
runLocalApplicationActivity: async (task, options) =>
|
||||||
|
localApplicationActivityOwner.runBoundedLocalApplicationActivity
|
||||||
|
? await localApplicationActivityOwner.runBoundedLocalApplicationActivity(task, options)
|
||||||
|
: await task(),
|
||||||
|
services: {
|
||||||
|
appLifecycle: services.appLifecycle,
|
||||||
|
path: services.path,
|
||||||
|
replication: services.replication,
|
||||||
|
vault: services.vault,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const unresolvedErrorManager = new UnresolvedErrorManager(services.appLifecycle, services.context.events);
|
||||||
|
const initialiseAutomaticReplicationTriggers = createAutomaticReplicationTriggers({
|
||||||
|
currentSettings: () => services.setting.currentSettings(),
|
||||||
|
isSuspended: () => services.appLifecycle.isSuspended(),
|
||||||
|
replicateDatabaseEvent: () =>
|
||||||
|
services.replication.replicateUnattendedByEvent({
|
||||||
|
trigger: "database-event",
|
||||||
|
interaction: NO_INTERACTION,
|
||||||
|
}),
|
||||||
|
reprocessStoredDocuments: () => resultProcessor.reprocessStoredDocuments(),
|
||||||
|
resumeResultApplication: () => resultProcessor.resume(),
|
||||||
|
suspendResultApplication: () => resultProcessor.suspend(),
|
||||||
|
});
|
||||||
|
const preflightContext = {
|
||||||
|
services: {
|
||||||
|
API: services.API,
|
||||||
|
replicator: services.replicator,
|
||||||
|
setting: services.setting,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const onlinePreflight = createOnlineReplicationPreflight(unresolvedErrorManager, preflightContext);
|
||||||
|
const securitySeedPreflight = createSecuritySeedPreflight(unresolvedErrorManager, preflightContext);
|
||||||
|
const centralCompatibilityRecovery = createCentralCompatibilityRecovery({
|
||||||
|
confirm: core.confirm,
|
||||||
|
localDatabase: core.localDatabase,
|
||||||
|
rebuilder: core.rebuilder,
|
||||||
|
services: {
|
||||||
|
API: services.API,
|
||||||
|
appLifecycle: services.appLifecycle,
|
||||||
|
replicator: services.replicator,
|
||||||
|
tweakValue: services.tweakValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
services.replicator.onBeforeReplicatorPublication.addHandler(() => {
|
||||||
|
// Key-derivation handlers belong to the candidate which is about to
|
||||||
|
// become active; discard callbacks retained by the previous owner.
|
||||||
|
clearHandlers();
|
||||||
|
return Promise.resolve(true);
|
||||||
|
});
|
||||||
|
services.databaseEvents.onDatabaseInitialised.addHandler(() => {
|
||||||
|
fireAndForget(() => resultProcessor.restoreFromSnapshotOnce());
|
||||||
|
return Promise.resolve(true);
|
||||||
|
});
|
||||||
|
services.appLifecycle.onSettingLoaded.addHandler(initialiseAutomaticReplicationTriggers);
|
||||||
|
services.replication.parseSynchroniseResult.addHandler((documents) => {
|
||||||
|
resultProcessor.enqueueAll(documents);
|
||||||
|
return Promise.resolve(true);
|
||||||
|
});
|
||||||
|
services.replication.onBeforeReplicate.addHandler(onlinePreflight, 10);
|
||||||
|
services.replication.onPrepareCentralRemoteReplication.addHandler(securitySeedPreflight);
|
||||||
|
services.replication.onBeforeReplicate.addHandler(async () => {
|
||||||
|
await resultProcessor.restoreFromSnapshotOnce();
|
||||||
|
unresolvedErrorManager.clearErrors();
|
||||||
|
return true;
|
||||||
|
}, 100);
|
||||||
|
services.replication.onReplicationFailed.addHandler(centralCompatibilityRecovery.handleReplicationFailure);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { $msg } from "@/common/translation";
|
||||||
|
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||||
|
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, Logger } from "octagonal-wheels/common/logger";
|
||||||
|
import { MARK_LOG_NETWORK_ERROR } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||||
|
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
|
||||||
|
import { REMOTE_RESOURCE_KINDS } from "@vrtmrz/livesync-commonlib/replication";
|
||||||
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||||
|
|
||||||
|
type ReplicationPreflightServices = Pick<LiveSyncBaseCore["services"], "API" | "replicator" | "setting">;
|
||||||
|
|
||||||
|
interface ReplicationPreflightContext {
|
||||||
|
readonly services: ReplicationPreflightServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the generic online preflight without inspecting a provider kind. */
|
||||||
|
export function createOnlineReplicationPreflight(
|
||||||
|
errorManager: UnresolvedErrorManager,
|
||||||
|
context: ReplicationPreflightContext
|
||||||
|
) {
|
||||||
|
return function isOnlineAndCanReplicate(showMessage: boolean): Promise<boolean> {
|
||||||
|
const errorMessage = "Network is offline";
|
||||||
|
if (!context.services.API.isOnline) {
|
||||||
|
errorManager.showError(errorMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
errorManager.clearError(errorMessage);
|
||||||
|
return Promise.resolve(true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the central-remote Security Seed preflight. The acquired resource is
|
||||||
|
* owned only for this read and is disposed before the handler settles.
|
||||||
|
*/
|
||||||
|
export function createSecuritySeedPreflight(
|
||||||
|
errorManager: UnresolvedErrorManager,
|
||||||
|
context: ReplicationPreflightContext
|
||||||
|
) {
|
||||||
|
return async function canReplicateWithSecuritySeed(showMessage: boolean): Promise<boolean> {
|
||||||
|
const currentSettings = context.services.setting.currentSettings();
|
||||||
|
const errorMessage = $msg("Replicator.Message.InitialiseFatalError");
|
||||||
|
// This is a fatal preparation error, so the non-interactive path still
|
||||||
|
// records it while choosing a quieter log level.
|
||||||
|
const ensureMessage = `${MARK_LOG_NETWORK_ERROR}Failed to initialise the encryption key, preventing replication.`;
|
||||||
|
try {
|
||||||
|
const resource = await context.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 true;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
|
import { VER, type EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import { promiseWithResolvers } from "octagonal-wheels/promises";
|
||||||
|
import { useReplicationFeature } from "./index";
|
||||||
|
|
||||||
|
type BooleanHandler = (showMessage: boolean) => Promise<boolean>;
|
||||||
|
type ParseHandler = (documents: PouchDB.Core.ExistingDocument<EntryDoc>[]) => Promise<boolean>;
|
||||||
|
|
||||||
|
function setup(onCloseActiveReplication = 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 beforeReplicateHandlers = new Map<number, BooleanHandler>();
|
||||||
|
const centralRemoteHandlers: BooleanHandler[] = [];
|
||||||
|
let parseHandler: ParseHandler | undefined;
|
||||||
|
const services = {
|
||||||
|
API: { isMobile: vi.fn(() => false), isOnline: true },
|
||||||
|
appLifecycle: {
|
||||||
|
getUnresolvedMessages: { addHandler: vi.fn() },
|
||||||
|
isReady: true,
|
||||||
|
isSuspended: vi.fn(() => false),
|
||||||
|
onSettingLoaded: { addHandler: vi.fn() },
|
||||||
|
},
|
||||||
|
context: createServiceContext(),
|
||||||
|
databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } },
|
||||||
|
keyValueDB: {
|
||||||
|
kvDB: {
|
||||||
|
get: vi.fn(async () => undefined),
|
||||||
|
set: vi.fn(async () => undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
path: { getPath: vi.fn((entry: { path: string }) => entry.path) },
|
||||||
|
replication: {
|
||||||
|
onBeforeReplicate: {
|
||||||
|
addHandler: vi.fn((handler: BooleanHandler, priority = 0) => {
|
||||||
|
beforeReplicateHandlers.set(priority, handler);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
onPrepareCentralRemoteReplication: {
|
||||||
|
addHandler: vi.fn((handler: BooleanHandler) => centralRemoteHandlers.push(handler)),
|
||||||
|
},
|
||||||
|
onReplicationFailed: { addHandler: vi.fn() },
|
||||||
|
parseSynchroniseResult: {
|
||||||
|
addHandler: vi.fn((handler: ParseHandler) => {
|
||||||
|
parseHandler = handler;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
replicateUnattendedByEvent: vi.fn(async () => ({ status: "completed" as const })),
|
||||||
|
},
|
||||||
|
replicator: {
|
||||||
|
createRemoteResource,
|
||||||
|
onBeforeReplicatorPublication: { addHandler: vi.fn() },
|
||||||
|
onCloseActiveReplication,
|
||||||
|
},
|
||||||
|
setting: { currentSettings: vi.fn(() => ({})) },
|
||||||
|
tweakValue: {},
|
||||||
|
vault: {},
|
||||||
|
};
|
||||||
|
const core = {
|
||||||
|
confirm: {},
|
||||||
|
localDatabase: {},
|
||||||
|
rebuilder: {},
|
||||||
|
services,
|
||||||
|
};
|
||||||
|
|
||||||
|
useReplicationFeature(core as never);
|
||||||
|
|
||||||
|
return {
|
||||||
|
beforeReplicateHandlers,
|
||||||
|
centralRemoteHandlers,
|
||||||
|
createRemoteResource,
|
||||||
|
dispose,
|
||||||
|
get parseHandler() {
|
||||||
|
return parseHandler;
|
||||||
|
},
|
||||||
|
onCloseActiveReplication,
|
||||||
|
read,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("replication serviceFeature composition", () => {
|
||||||
|
it("refreshes and disposes the remote Security Seed before central replication", async () => {
|
||||||
|
const { centralRemoteHandlers, createRemoteResource, dispose, read } = setup();
|
||||||
|
|
||||||
|
await expect(centralRemoteHandlers[0](false)).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(createRemoteResource).toHaveBeenCalledWith("security-seed", {});
|
||||||
|
expect(read).toHaveBeenCalledOnce();
|
||||||
|
expect(dispose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps generic preflight separate from central-remote preparation", async () => {
|
||||||
|
const { beforeReplicateHandlers, centralRemoteHandlers, createRemoteResource } = setup();
|
||||||
|
const online = beforeReplicateHandlers.get(10);
|
||||||
|
const general = beforeReplicateHandlers.get(100);
|
||||||
|
|
||||||
|
expect(online).toBeDefined();
|
||||||
|
expect(general).toBeDefined();
|
||||||
|
expect(centralRemoteHandlers).toHaveLength(1);
|
||||||
|
await expect(online!(false)).resolves.toBe(true);
|
||||||
|
await expect(general!(false)).resolves.toBe(true);
|
||||||
|
expect(createRemoteResource).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await expect(centralRemoteHandlers[0](false)).resolves.toBe(true);
|
||||||
|
expect(createRemoteResource).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requests owner retirement without awaiting the transition from result application", async () => {
|
||||||
|
const retirement = promiseWithResolvers<boolean>();
|
||||||
|
const onCloseActiveReplication = vi.fn(() => retirement.promise);
|
||||||
|
const harness = setup(onCloseActiveReplication);
|
||||||
|
const versionInfo = {
|
||||||
|
_id: "versioninfo",
|
||||||
|
_rev: "1-test",
|
||||||
|
type: "versioninfo",
|
||||||
|
version: VER + 1,
|
||||||
|
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
|
||||||
|
|
||||||
|
expect(harness.parseHandler).toBeDefined();
|
||||||
|
await expect(harness.parseHandler!([versionInfo])).resolves.toBe(true);
|
||||||
|
expect(onCloseActiveReplication).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
retirement.resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user