mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-02 22:38:18 +00:00
Retire legacy purge-based cleanup recovery
This commit is contained in:
1 parent
f325d2454d
commit
d1af8a5dfa
4 files changed
+129
-223
No files matched your search
@@ -1,48 +1,22 @@
|
|||||||
import {
|
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
VERSIONING_DOCID,
|
|
||||||
type EntryDoc,
|
|
||||||
type ObsidianLiveSyncSettings,
|
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
|
||||||
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
|
import { assessTweakCompatibility } from "@vrtmrz/livesync-commonlib/settings";
|
||||||
import { LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, Logger } from "octagonal-wheels/common/logger";
|
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 {
|
import {
|
||||||
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||||
REPLICATION_PROGRESS_PRESENTATIONS,
|
|
||||||
assessRemoteFeatureDocument,
|
|
||||||
describeRemoteFeatureRejection,
|
|
||||||
type ReplicatorInstance,
|
type ReplicatorInstance,
|
||||||
type ReplicationFailureRequest,
|
type ReplicationFailureRequest,
|
||||||
} from "@vrtmrz/livesync-commonlib/replication";
|
} from "@vrtmrz/livesync-commonlib/replication";
|
||||||
import { $msg } from "@/common/translation";
|
import { $msg } from "@/common/translation";
|
||||||
import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings";
|
|
||||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||||
import type PouchDB from "pouchdb-core";
|
|
||||||
|
|
||||||
async function canInterpretCleanupDatabase(db: PouchDB.Database<EntryDoc>): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const assessment = assessRemoteFeatureDocument(await db.get(VERSIONING_DOCID));
|
|
||||||
if (assessment.status === "supported" || assessment.status === "older-generation") return true;
|
|
||||||
Logger(`Database cleanup cancelled: ${describeRemoteFeatureRejection(assessment)}`, LOG_LEVEL_NOTICE);
|
|
||||||
} catch (error) {
|
|
||||||
Logger("Database cleanup cancelled: feature compatibility could not be checked.", LOG_LEVEL_NOTICE);
|
|
||||||
Logger(error, LOG_LEVEL_INFO);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CentralCompatibilityRecoveryServices = Pick<
|
type CentralCompatibilityRecoveryServices = Pick<
|
||||||
LiveSyncBaseCore["services"],
|
LiveSyncBaseCore["services"],
|
||||||
"API" | "appLifecycle" | "replicator" | "setting" | "tweakValue"
|
"appLifecycle" | "replicator" | "setting" | "tweakValue"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/** Collaborators for applying a compatibility decision to its failed publication. */
|
/** Collaborators for applying a compatibility decision to its failed publication. */
|
||||||
interface CentralCompatibilityRecoveryContext {
|
interface CentralCompatibilityRecoveryContext {
|
||||||
readonly confirm: LiveSyncBaseCore["confirm"];
|
readonly confirm: LiveSyncBaseCore["confirm"];
|
||||||
/** Obtain the database only when recovery runs, after initialisation or reset. */
|
|
||||||
readonly getLocalDatabase: () => LiveSyncBaseCore["localDatabase"];
|
|
||||||
readonly rebuilder: LiveSyncBaseCore["rebuilder"];
|
readonly rebuilder: LiveSyncBaseCore["rebuilder"];
|
||||||
readonly services: CentralCompatibilityRecoveryServices;
|
readonly services: CentralCompatibilityRecoveryServices;
|
||||||
}
|
}
|
||||||
@@ -72,89 +46,11 @@ function canMarkRemoteResolved(replicator: ReplicatorInstance): replicator is Re
|
|||||||
* replacement; the failure result is never re-read from the current instance.
|
* replacement; the failure result is never re-read from the current instance.
|
||||||
*/
|
*/
|
||||||
export function createCentralCompatibilityRecovery(context: CentralCompatibilityRecoveryContext) {
|
export function createCentralCompatibilityRecovery(context: CentralCompatibilityRecoveryContext) {
|
||||||
async function reconcileCleanedRemote(
|
|
||||||
showProgress: boolean,
|
|
||||||
setting: ObsidianLiveSyncSettings,
|
|
||||||
expectedContext: ReplicationFailureRequest["context"]
|
|
||||||
) {
|
|
||||||
Logger("The remote database has been cleaned.", showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
|
||||||
await skipIfDuplicated("cleanup", async () => {
|
|
||||||
if (!(await canInterpretCleanupDatabase(context.getLocalDatabase().localDatabase))) return;
|
|
||||||
const count = await purgeUnreferencedChunks(context.getLocalDatabase().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 localDatabase = context.getLocalDatabase();
|
|
||||||
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(localDatabase.localDatabase, false);
|
|
||||||
localDatabase.clearCaches();
|
|
||||||
const replicated = await context.services.replicator.runFiniteReplicationActivity(
|
|
||||||
() => replicator.openOneShotReplication(setting, showProgress, false, "sync", true),
|
|
||||||
{ label: "replication" }
|
|
||||||
);
|
|
||||||
if (replicated) {
|
|
||||||
await balanceChunkPurgedDBs(localDatabase.localDatabase, remoteDatabase.db);
|
|
||||||
await purgeUnreferencedChunks(localDatabase.localDatabase, false);
|
|
||||||
localDatabase.clearCaches();
|
|
||||||
await replicator.markRemoteResolved(setting);
|
|
||||||
Logger(
|
|
||||||
"The local database has been cleaned up.",
|
|
||||||
showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
Logger(
|
|
||||||
"Replication has been cancelled. Please try it again.",
|
|
||||||
showProgress ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await remoteDatabase.close();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
{ label: "database-cleanup" }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleReplicationFailure(request: ReplicationFailureRequest): Promise<boolean> {
|
async function handleReplicationFailure(request: ReplicationFailureRequest): Promise<boolean> {
|
||||||
const { context: failedContext, interaction, outcome, progressPresentation, setting } = request;
|
const { context: failedContext, interaction, outcome, setting } = request;
|
||||||
const showProgress = progressPresentation === REPLICATION_PROGRESS_PRESENTATIONS.NOTICE;
|
|
||||||
if (interaction.kind === "forbidden") {
|
if (interaction.kind === "forbidden") {
|
||||||
// Automatic requests may report the failure, but must not enter
|
// Automatic requests may report the failure, but must not enter
|
||||||
// tweak, lock, fetch, unlock, or cleanup dialogues.
|
// tweak, lock, fetch, or unlock dialogues.
|
||||||
Logger("Replication failed on an unattended path.", LOG_LEVEL_INFO);
|
Logger("Replication failed on an unattended path.", LOG_LEVEL_INFO);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -198,14 +94,6 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
|||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED &&
|
|
||||||
usesLegacyIndexedDBAdapter(setting)
|
|
||||||
) {
|
|
||||||
await reconcileCleanedRemote(showProgress, setting, failedContext);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = $msg("Replicator.Dialogue.Locked.Message");
|
const message = $msg("Replicator.Dialogue.Locked.Message");
|
||||||
const CHOICE_FETCH = $msg("Replicator.Dialogue.Locked.Action.Fetch");
|
const CHOICE_FETCH = $msg("Replicator.Dialogue.Locked.Action.Fetch");
|
||||||
const CHOICE_DISMISS = $msg("Replicator.Dialogue.Locked.Action.Dismiss");
|
const CHOICE_DISMISS = $msg("Replicator.Dialogue.Locked.Action.Dismiss");
|
||||||
@@ -240,5 +128,5 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.freeze({ handleReplicationFailure, reconcileCleanedRemote });
|
return Object.freeze({ handleReplicationFailure });
|
||||||
}
|
}
|
||||||
@@ -16,55 +16,86 @@ const chunkMocks = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/chunks", () => chunkMocks);
|
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";
|
import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery";
|
||||||
|
|
||||||
describe("central compatibility recovery", () => {
|
describe("central compatibility recovery", () => {
|
||||||
it("does not count chunks for cleanup when local feature requirements are unknown", async () => {
|
describe.each([
|
||||||
chunkMocks.purgeUnreferencedChunks.mockClear();
|
{ name: "unknown local features", version: 13, used_features: ["future-format-v7"] },
|
||||||
const confirmWithMessage = vi.fn(async () => "Dismiss");
|
{ name: "a legacy local version", version: 11 },
|
||||||
const recovery = createCentralCompatibilityRecovery({
|
{ name: "the current local version", version: 12 },
|
||||||
confirm: { confirmWithMessage },
|
])("compatibility: cleaned IndexedDB remote with $name", (localVersion) => {
|
||||||
getLocalDatabase: () => ({
|
it.each(["fetch", "unlock", "dismiss"] as const)(
|
||||||
localDatabase: {
|
"uses standard recovery for an explicit %s choice without legacy cleanup",
|
||||||
get: vi.fn(async (id: string) => ({
|
async (choice) => {
|
||||||
_id: id,
|
chunkMocks.purgeUnreferencedChunks.mockClear();
|
||||||
type: "versioninfo",
|
chunkMocks.balanceChunkPurgedDBs.mockClear();
|
||||||
version: 13,
|
const markRemoteResolved = vi.fn(async (_setting: unknown) => undefined);
|
||||||
used_features: ["future-format-v7"],
|
const failedContext = {
|
||||||
})),
|
provider: {},
|
||||||
},
|
replicator: { markRemoteResolved },
|
||||||
}),
|
configurationIdentity: "cleaned-remote-test",
|
||||||
services: { replicator: {} },
|
};
|
||||||
} as never);
|
const setting = { useIndexedDBAdapter: true } as ObsidianLiveSyncSettings;
|
||||||
|
const scheduleFetch = vi.fn(async () => undefined);
|
||||||
|
const scheduleRestart = vi.fn();
|
||||||
|
const getLocalDatabase = vi.fn(() => ({
|
||||||
|
localDatabase: {
|
||||||
|
get: vi.fn(async () => ({ _id: VERSIONING_DOCID, type: "versioninfo", ...localVersion })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const confirmWithMessage = vi.fn(async () => "Dismiss");
|
||||||
|
const askSelectStringDialogue = vi.fn(
|
||||||
|
async (_message: string, choices: string[]) =>
|
||||||
|
choices[choice === "fetch" ? 0 : choice === "unlock" ? 1 : 2]
|
||||||
|
);
|
||||||
|
const recovery = createCentralCompatibilityRecovery({
|
||||||
|
confirm: { askSelectStringDialogue, confirmWithMessage },
|
||||||
|
getLocalDatabase,
|
||||||
|
rebuilder: { scheduleFetch },
|
||||||
|
services: {
|
||||||
|
appLifecycle: { scheduleRestart },
|
||||||
|
replicator: {
|
||||||
|
runWithActiveReplicatorContext: async (task: (context: unknown) => unknown) =>
|
||||||
|
await task(failedContext),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, {} as never);
|
const result = await recovery.handleReplicationFailure({
|
||||||
|
context: failedContext,
|
||||||
|
setting,
|
||||||
|
outcome: replicationFailed(new Error("cleaned"), {
|
||||||
|
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED,
|
||||||
|
}),
|
||||||
|
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||||
|
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
|
} as never);
|
||||||
|
|
||||||
expect(chunkMocks.purgeUnreferencedChunks).not.toHaveBeenCalled();
|
expect(result).toBe(false);
|
||||||
expect(confirmWithMessage).not.toHaveBeenCalled();
|
expect(askSelectStringDialogue).toHaveBeenCalledOnce();
|
||||||
});
|
const choices = askSelectStringDialogue.mock.calls[0][1];
|
||||||
|
expect(choices).toHaveLength(3);
|
||||||
it("allows cleanup counting for a legacy local version document", async () => {
|
expect(askSelectStringDialogue).toHaveBeenCalledWith(expect.any(String), choices, {
|
||||||
chunkMocks.purgeUnreferencedChunks.mockClear();
|
title: expect.any(String),
|
||||||
const confirmWithMessage = vi.fn(async () => "Dismiss");
|
defaultAction: choices[2],
|
||||||
const recovery = createCentralCompatibilityRecovery({
|
timeout: 60,
|
||||||
confirm: { confirmWithMessage },
|
});
|
||||||
getLocalDatabase: () => ({
|
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||||
localDatabase: {
|
expect(getLocalDatabase).not.toHaveBeenCalled();
|
||||||
get: vi.fn(async (id: string) => ({ _id: id, type: "versioninfo", version: 11 })),
|
expect(chunkMocks.purgeUnreferencedChunks).not.toHaveBeenCalled();
|
||||||
},
|
expect(chunkMocks.balanceChunkPurgedDBs).not.toHaveBeenCalled();
|
||||||
}),
|
expect(scheduleFetch).toHaveBeenCalledTimes(choice === "fetch" ? 1 : 0);
|
||||||
services: { replicator: {} },
|
expect(scheduleRestart).toHaveBeenCalledTimes(choice === "fetch" ? 1 : 0);
|
||||||
} as never);
|
expect(markRemoteResolved).toHaveBeenCalledTimes(choice === "unlock" ? 1 : 0);
|
||||||
|
if (choice === "fetch") {
|
||||||
await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, {} as never);
|
expect(scheduleFetch.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
scheduleRestart.mock.invocationCallOrder[0]
|
||||||
expect(chunkMocks.purgeUnreferencedChunks).toHaveBeenCalledWith(expect.anything(), true);
|
);
|
||||||
expect(confirmWithMessage).toHaveBeenCalledOnce();
|
}
|
||||||
|
if (choice === "unlock") expect(markRemoteResolved).toHaveBeenCalledWith(setting);
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes the failed attempt's exact tweak assessment to mismatch resolution", async () => {
|
it("passes the failed attempt's exact tweak assessment to mismatch resolution", async () => {
|
||||||
@@ -139,7 +170,6 @@ describe("central compatibility recovery", () => {
|
|||||||
try {
|
try {
|
||||||
const recovery = createCentralCompatibilityRecovery({
|
const recovery = createCentralCompatibilityRecovery({
|
||||||
confirm: {},
|
confirm: {},
|
||||||
getLocalDatabase: () => ({}),
|
|
||||||
rebuilder: {},
|
rebuilder: {},
|
||||||
services: {
|
services: {
|
||||||
appLifecycle: {},
|
appLifecycle: {},
|
||||||
@@ -187,7 +217,6 @@ describe("central compatibility recovery", () => {
|
|||||||
});
|
});
|
||||||
const recovery = createCentralCompatibilityRecovery({
|
const recovery = createCentralCompatibilityRecovery({
|
||||||
confirm: {},
|
confirm: {},
|
||||||
getLocalDatabase: () => ({}),
|
|
||||||
rebuilder: {},
|
rebuilder: {},
|
||||||
services: {
|
services: {
|
||||||
appLifecycle: {},
|
appLifecycle: {},
|
||||||
@@ -255,7 +284,6 @@ describe("central compatibility recovery", () => {
|
|||||||
);
|
);
|
||||||
const recovery = createCentralCompatibilityRecovery({
|
const recovery = createCentralCompatibilityRecovery({
|
||||||
confirm: {},
|
confirm: {},
|
||||||
getLocalDatabase: () => ({}),
|
|
||||||
rebuilder: {},
|
rebuilder: {},
|
||||||
services: {
|
services: {
|
||||||
appLifecycle: {},
|
appLifecycle: {},
|
||||||
@@ -287,7 +315,10 @@ describe("central compatibility recovery", () => {
|
|||||||
expect(setPreferredRemoteTweakSettings.mock.calls[0][0]).not.toBe(effectiveSetting);
|
expect(setPreferredRemoteTweakSettings.mock.calls[0][0]).not.toBe(effectiveSetting);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not apply an unlock selected for a replaced failed publication", async () => {
|
it.each([
|
||||||
|
CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
|
||||||
|
CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED,
|
||||||
|
])("does not apply an unlock for a replaced failed publication: %s", async (reason) => {
|
||||||
const failedMarkResolved = vi.fn(async () => undefined);
|
const failedMarkResolved = vi.fn(async () => undefined);
|
||||||
const replacementMarkResolved = vi.fn(async () => undefined);
|
const replacementMarkResolved = vi.fn(async () => undefined);
|
||||||
const failedContext = { provider: {}, replicator: { markRemoteResolved: failedMarkResolved } };
|
const failedContext = { provider: {}, replicator: { markRemoteResolved: failedMarkResolved } };
|
||||||
@@ -299,7 +330,6 @@ describe("central compatibility recovery", () => {
|
|||||||
confirm: {
|
confirm: {
|
||||||
askSelectStringDialogue: vi.fn(async (_message: string, choices: string[]) => choices[1]),
|
askSelectStringDialogue: vi.fn(async (_message: string, choices: string[]) => choices[1]),
|
||||||
},
|
},
|
||||||
getLocalDatabase: () => ({}),
|
|
||||||
rebuilder: {},
|
rebuilder: {},
|
||||||
services: {
|
services: {
|
||||||
appLifecycle: { scheduleRestart: vi.fn() },
|
appLifecycle: { scheduleRestart: vi.fn() },
|
||||||
@@ -311,9 +341,9 @@ describe("central compatibility recovery", () => {
|
|||||||
|
|
||||||
await recovery.handleReplicationFailure({
|
await recovery.handleReplicationFailure({
|
||||||
context: failedContext,
|
context: failedContext,
|
||||||
setting: {},
|
setting: { useIndexedDBAdapter: true },
|
||||||
outcome: replicationFailed(new Error("locked"), {
|
outcome: replicationFailed(new Error("locked"), {
|
||||||
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
|
reason,
|
||||||
}),
|
}),
|
||||||
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||||
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
@@ -324,69 +354,55 @@ describe("central compatibility recovery", () => {
|
|||||||
expect(replacementMarkResolved).not.toHaveBeenCalled();
|
expect(replacementMarkResolved).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps cleaned-remote replication and balancing inside the shared activity boundary", async () => {
|
it.each([
|
||||||
const activityFinished = vi.fn();
|
NO_INTERACTION,
|
||||||
const runBoundedRemoteActivity = vi.fn(async (task: () => unknown) => {
|
{
|
||||||
try {
|
kind: "permitted" as const,
|
||||||
return await task();
|
permissions: { ...USER_INITIATED_REPLICATION_AUTHORITY.permissions, failureRecovery: false },
|
||||||
} finally {
|
},
|
||||||
activityFinished();
|
])("does not recover a cleaned remote without recovery authority: %j", async (interaction) => {
|
||||||
}
|
const askSelectStringDialogue = vi.fn();
|
||||||
});
|
const confirmWithMessage = vi.fn();
|
||||||
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
const getLocalDatabase = vi.fn();
|
||||||
const openOneShotReplication = vi.fn(async () => true);
|
const scheduleFetch = vi.fn();
|
||||||
const remoteDatabase = {
|
const scheduleRestart = vi.fn();
|
||||||
close: vi.fn(async () => undefined),
|
const markRemoteResolved = vi.fn();
|
||||||
|
const failedContext = {
|
||||||
|
provider: {},
|
||||||
|
replicator: { markRemoteResolved },
|
||||||
|
configurationIdentity: "cleaned-remote-test",
|
||||||
};
|
};
|
||||||
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) =>
|
const runWithActiveReplicatorContext = vi.fn(async (task: (context: unknown) => unknown) =>
|
||||||
task(expectedContext)
|
task(failedContext)
|
||||||
);
|
);
|
||||||
const localDatabase = {
|
|
||||||
localDatabase: {
|
|
||||||
get: vi.fn(async () => ({ _id: VERSIONING_DOCID, type: "versioninfo", version: 12 })),
|
|
||||||
},
|
|
||||||
clearCaches: vi.fn(),
|
|
||||||
};
|
|
||||||
const getLocalDatabase = vi.fn(() => localDatabase);
|
|
||||||
const recovery = createCentralCompatibilityRecovery({
|
const recovery = createCentralCompatibilityRecovery({
|
||||||
confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") },
|
confirm: { askSelectStringDialogue, confirmWithMessage },
|
||||||
getLocalDatabase,
|
getLocalDatabase,
|
||||||
rebuilder: {},
|
rebuilder: { scheduleFetch },
|
||||||
services: {
|
services: {
|
||||||
appLifecycle: {},
|
appLifecycle: { scheduleRestart },
|
||||||
API: { isMobile: vi.fn(() => false) },
|
replicator: { runWithActiveReplicatorContext },
|
||||||
replicator: {
|
|
||||||
runBoundedRemoteActivity,
|
|
||||||
runFiniteReplicationActivity,
|
|
||||||
runWithActiveReplicatorContext,
|
|
||||||
},
|
|
||||||
tweakValue: {},
|
|
||||||
},
|
},
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, expectedContext as never);
|
await expect(
|
||||||
|
recovery.handleReplicationFailure({
|
||||||
|
context: failedContext,
|
||||||
|
setting: { useIndexedDBAdapter: true },
|
||||||
|
outcome: replicationFailed(new Error("cleaned"), {
|
||||||
|
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED,
|
||||||
|
}),
|
||||||
|
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
|
||||||
|
interaction,
|
||||||
|
} as never)
|
||||||
|
).resolves.toBe(false);
|
||||||
|
|
||||||
expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), {
|
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||||
label: "database-cleanup",
|
expect(confirmWithMessage).not.toHaveBeenCalled();
|
||||||
});
|
expect(getLocalDatabase).not.toHaveBeenCalled();
|
||||||
expect(runFiniteReplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
|
expect(scheduleFetch).not.toHaveBeenCalled();
|
||||||
label: "replication",
|
expect(scheduleRestart).not.toHaveBeenCalled();
|
||||||
});
|
expect(runWithActiveReplicatorContext).not.toHaveBeenCalled();
|
||||||
expect(runWithActiveReplicatorContext).toHaveBeenCalledOnce();
|
expect(markRemoteResolved).not.toHaveBeenCalled();
|
||||||
expect(openOneShotReplication).toHaveBeenCalledOnce();
|
|
||||||
expect(openOneShotReplication.mock.invocationCallOrder[0]).toBeLessThan(
|
|
||||||
activityFinished.mock.invocationCallOrder[0]
|
|
||||||
);
|
|
||||||
expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce();
|
|
||||||
expect(getLocalDatabase).toHaveBeenCalled();
|
|
||||||
expect(close).toHaveBeenCalledOnce();
|
|
||||||
expect(close.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -83,10 +83,8 @@ export function useReplicationFeature<TContext extends ServiceContext, TCommands
|
|||||||
const securitySeedPreflight = createSecuritySeedPreflight(unresolvedErrorManager, preflightContext);
|
const securitySeedPreflight = createSecuritySeedPreflight(unresolvedErrorManager, preflightContext);
|
||||||
const centralCompatibilityRecovery = createCentralCompatibilityRecovery({
|
const centralCompatibilityRecovery = createCentralCompatibilityRecovery({
|
||||||
confirm: core.confirm,
|
confirm: core.confirm,
|
||||||
getLocalDatabase: () => core.localDatabase,
|
|
||||||
rebuilder: core.rebuilder,
|
rebuilder: core.rebuilder,
|
||||||
services: {
|
services: {
|
||||||
API: services.API,
|
|
||||||
appLifecycle: services.appLifecycle,
|
appLifecycle: services.appLifecycle,
|
||||||
replicator: services.replicator,
|
replicator: services.replicator,
|
||||||
setting: services.setting,
|
setting: services.setting,
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
|||||||
|
|
||||||
### Synchronisation
|
### Synchronisation
|
||||||
|
|
||||||
|
- Devices using the legacy IndexedDB adapter now use the standard recovery dialogue when a remote retains an old cleanup lock.
|
||||||
|
- The obsolete in-place `Cleanup` option has been removed. We can fetch the remote again, explicitly unlock the device, or dismiss the dialogue; dismissal remains the default.
|
||||||
|
- Fetch replaces the local database, so preserve unsynchronised work and any required local history first. Unlock only when the local and remote databases are already aligned. Garbage Collection V3 is unchanged.
|
||||||
|
|
||||||
- Added an optional, device-local editing guard for substantial CouchDB LiveSync catch-up transfers. When enough incoming notes are detected, it covers the workspace until the transfer and local application queues have settled. The overlay can always be bypassed, and ordinary small transfers remain uninterrupted.
|
- Added an optional, device-local editing guard for substantial CouchDB LiveSync catch-up transfers. When enough incoming notes are detected, it covers the workspace until the transfer and local application queues have settled. The overlay can always be bypassed, and ordinary small transfers remain uninterrupted.
|
||||||
|
|
||||||
- We can see CouchDB change-feed comparison progress separately for push and pull.
|
- We can see CouchDB change-feed comparison progress separately for push and pull.
|
||||||
|
|||||||
Reference in new issue
Block a user