mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-29 20:43:20 +00:00
Adopt active Replicator ownership contracts
This commit is contained in:
1 parent
db70b4c2b6
commit
7300db08d6
18 files changed
+984
-410
No files matched your search
+11
-4
@@ -16,7 +16,11 @@ import type { LiveSyncLocalDBEnv } from "@vrtmrz/livesync-commonlib/compat/pouch
|
|||||||
import type { LiveSyncCouchDBReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
import type { LiveSyncCouchDBReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||||
import type { CheckPointInfo } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncTypes";
|
import type { CheckPointInfo } from "@vrtmrz/livesync-commonlib/compat/replication/journal/JournalSyncTypes";
|
||||||
import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicatorEnv";
|
import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicatorEnv";
|
||||||
import type { LiveSyncReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
import type {
|
||||||
|
LiveSyncAbstractReplicator,
|
||||||
|
LiveSyncReplicatorEnv,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
||||||
|
import type { ReplicatorInstance } from "@vrtmrz/livesync-commonlib/replication";
|
||||||
import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter";
|
import { useTargetFilters } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/targetFilter";
|
||||||
import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
import { useRemoteConfigurationMigration } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
|
||||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
@@ -39,6 +43,8 @@ export interface LiveSyncCoreFeatureViews {
|
|||||||
readonly replicationScheduling: ReplicationSchedulingControl;
|
readonly replicationScheduling: ReplicationSchedulingControl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CompatibilityReplicatorView = ReplicatorInstance & Partial<LiveSyncAbstractReplicator>;
|
||||||
|
|
||||||
export class LiveSyncBaseCore<
|
export class LiveSyncBaseCore<
|
||||||
T extends ServiceContext = ServiceContext,
|
T extends ServiceContext = ServiceContext,
|
||||||
TCommands extends IMinimumLiveSyncCommands = IMinimumLiveSyncCommands,
|
TCommands extends IMinimumLiveSyncCommands = IMinimumLiveSyncCommands,
|
||||||
@@ -236,10 +242,11 @@ export class LiveSyncBaseCore<
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @obsolete Use services.replication.getActiveReplicator instead. Get the active replicator instance. Note that there can be multiple replicators, but only one can be active at a time.
|
* @obsolete Use the provider context or a focused service operation instead.
|
||||||
|
* Provider-specific members on this compatibility view are optional.
|
||||||
*/
|
*/
|
||||||
get replicator() {
|
get replicator(): CompatibilityReplicatorView {
|
||||||
return this.services.replicator.getActiveReplicator()!;
|
return this.services.replicator.getActiveReplicator() as CompatibilityReplicatorView;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+40
-35
@@ -1,27 +1,29 @@
|
|||||||
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import {
|
import {
|
||||||
REMOTE_ADMINISTRATION_ACTIONS,
|
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||||
REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||||
REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||||
isRemoteAdministrationVerified,
|
isCentralRemoteAdministrationVerified,
|
||||||
type RemoteAdministrationAction,
|
type CentralRemoteAdministrationAction,
|
||||||
type RemoteAdministrationResult,
|
type CentralRemoteAdministrationResult,
|
||||||
} from "@vrtmrz/livesync-commonlib/replication";
|
} from "@vrtmrz/livesync-commonlib/replication";
|
||||||
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||||
import { writeStderrLine } from "@/apps/cli/cliOutput";
|
import { writeStderrLine } from "@/apps/cli/cliOutput";
|
||||||
import type { CLICommand, CLICommandContext, CLIOptions } from "./types";
|
import type { CLICommand, CLICommandContext, CLIOptions } from "./types";
|
||||||
|
|
||||||
const REMOTE_ADMINISTRATION_ACTION_BY_COMMAND = Object.freeze({
|
const CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND = Object.freeze({
|
||||||
"mark-resolved": REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
"mark-resolved": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||||
"lock-remote": REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
"lock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||||
"unlock-remote": REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
"unlock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||||
} as const satisfies Partial<Record<CLICommand, RemoteAdministrationAction>>);
|
} as const satisfies Partial<Record<CLICommand, CentralRemoteAdministrationAction>>);
|
||||||
|
|
||||||
export type RemoteAdministrationCommand = keyof typeof REMOTE_ADMINISTRATION_ACTION_BY_COMMAND;
|
export type CentralRemoteAdministrationCommand = keyof typeof CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND;
|
||||||
|
|
||||||
/** Return whether a CLI command belongs to the remote-administration category. */
|
/** Return whether a CLI command belongs to the central-remote administration category. */
|
||||||
export function isRemoteAdministrationCommand(command: CLICommand): command is RemoteAdministrationCommand {
|
export function isCentralRemoteAdministrationCommand(
|
||||||
return Object.prototype.hasOwnProperty.call(REMOTE_ADMINISTRATION_ACTION_BY_COMMAND, command);
|
command: CLICommand
|
||||||
|
): command is CentralRemoteAdministrationCommand {
|
||||||
|
return Object.prototype.hasOwnProperty.call(CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
function detailMessage(detail: unknown): string {
|
function detailMessage(detail: unknown): string {
|
||||||
@@ -31,8 +33,8 @@ function detailMessage(detail: unknown): string {
|
|||||||
function reportMilestoneObservation(
|
function reportMilestoneObservation(
|
||||||
standardIo: StandardIo,
|
standardIo: StandardIo,
|
||||||
observation: Extract<
|
observation: Extract<
|
||||||
RemoteAdministrationResult["observation"],
|
CentralRemoteAdministrationResult["observation"],
|
||||||
{ kind: typeof REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE }
|
{ kind: typeof CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE }
|
||||||
>
|
>
|
||||||
): void {
|
): void {
|
||||||
standardIo.writeStderr(`[Verification] Remote Database: ${observation.locked ? "LOCKED" : "UNLOCKED"}\n`);
|
standardIo.writeStderr(`[Verification] Remote Database: ${observation.locked ? "LOCKED" : "UNLOCKED"}\n`);
|
||||||
@@ -42,40 +44,43 @@ function reportMilestoneObservation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Map typed provider observations to the CLI's established verification output. */
|
/** Map typed provider observations to the CLI's established verification output. */
|
||||||
function reportRemoteAdministrationResult(standardIo: StandardIo, result: RemoteAdministrationResult): void {
|
function reportCentralRemoteAdministrationResult(
|
||||||
if (result.observation?.kind === REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE) {
|
standardIo: StandardIo,
|
||||||
|
result: CentralRemoteAdministrationResult
|
||||||
|
): void {
|
||||||
|
if (result.observation?.kind === CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE) {
|
||||||
reportMilestoneObservation(standardIo, result.observation);
|
reportMilestoneObservation(standardIo, result.observation);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isRemoteAdministrationVerified(result)) {
|
if (isCentralRemoteAdministrationVerified(result)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (result.reason) {
|
switch (result.reason) {
|
||||||
case REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR:
|
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR:
|
||||||
standardIo.writeStderr("[Verification] No active replicator found\n");
|
standardIo.writeStderr("[Verification] No active replicator found\n");
|
||||||
return;
|
return;
|
||||||
case REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED:
|
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED:
|
||||||
standardIo.writeStderr(
|
standardIo.writeStderr(
|
||||||
`[Verification] Failed to connect to remote CouchDB: ${detailMessage(result.detail)}\n`
|
`[Verification] Failed to connect to the configured remote: ${detailMessage(result.detail)}\n`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
case REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND:
|
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND:
|
||||||
standardIo.writeStderr("[Verification] Milestone document not found on remote.\n");
|
standardIo.writeStderr("[Verification] Milestone document not found on remote.\n");
|
||||||
return;
|
return;
|
||||||
case REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED:
|
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED:
|
||||||
standardIo.writeStderr(
|
standardIo.writeStderr(
|
||||||
`[Verification] Failed to fetch milestone document: ${detailMessage(result.detail)}\n`
|
`[Verification] Failed to fetch milestone document: ${detailMessage(result.detail)}\n`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
case REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE:
|
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE:
|
||||||
standardIo.writeStderr("[Verification] Failed to initialise the current device identity.\n");
|
standardIo.writeStderr("[Verification] Failed to initialise the current device identity.\n");
|
||||||
return;
|
return;
|
||||||
case REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_IMPLEMENTED:
|
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_IMPLEMENTED:
|
||||||
case REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE:
|
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE:
|
||||||
standardIo.writeStderr("[Verification] Remote administration is unavailable for this provider.\n");
|
standardIo.writeStderr("[Verification] Remote administration is unavailable for this provider.\n");
|
||||||
return;
|
return;
|
||||||
case REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH:
|
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH:
|
||||||
standardIo.writeStderr("[Verification] The requested remote state was not observed.\n");
|
standardIo.writeStderr("[Verification] The requested remote state was not observed.\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -85,10 +90,10 @@ function reportRemoteAdministrationResult(standardIo: StandardIo, result: Remote
|
|||||||
* Apply one provider-owned mutation and map its typed verification to CLI exit policy.
|
* Apply one provider-owned mutation and map its typed verification to CLI exit policy.
|
||||||
* Mutation exceptions deliberately escape this boundary.
|
* Mutation exceptions deliberately escape this boundary.
|
||||||
*/
|
*/
|
||||||
export async function runRemoteAdministrationCommand(
|
export async function runCentralRemoteAdministrationCommand(
|
||||||
options: CLIOptions,
|
options: CLIOptions,
|
||||||
context: CLICommandContext,
|
context: CLICommandContext,
|
||||||
command: RemoteAdministrationCommand
|
command: CentralRemoteAdministrationCommand
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const id = options.commandArgs[0]?.trim();
|
const id = options.commandArgs[0]?.trim();
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -113,8 +118,8 @@ export async function runRemoteAdministrationCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeStderrLine(context.core.services.context.standardIo, `[Command] ${command}${id ? ` ${id}` : ""}`);
|
writeStderrLine(context.core.services.context.standardIo, `[Command] ${command}${id ? ` ${id}` : ""}`);
|
||||||
const action = REMOTE_ADMINISTRATION_ACTION_BY_COMMAND[command];
|
const action = CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND[command];
|
||||||
const result = await context.core.services.replicator.runRemoteAdministration({ action });
|
const result = await context.core.services.replicator.runCentralRemoteAdministration({ action });
|
||||||
reportRemoteAdministrationResult(context.core.services.context.standardIo, result);
|
reportCentralRemoteAdministrationResult(context.core.services.context.standardIo, result);
|
||||||
return isRemoteAdministrationVerified(result) || options.compatRemoteAdminExitZero === true;
|
return isCentralRemoteAdministrationVerified(result) || options.compatRemoteAdminExitZero === true;
|
||||||
}
|
}
|
||||||
@@ -21,13 +21,17 @@ import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFu
|
|||||||
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||||
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
|
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
|
||||||
import {
|
import {
|
||||||
|
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||||
isReplicationCompleted,
|
isReplicationCompleted,
|
||||||
NO_INTERACTION,
|
NO_INTERACTION,
|
||||||
REMOTE_RESOURCE_KINDS,
|
REMOTE_RESOURCE_KINDS,
|
||||||
USER_INITIATED_REPLICATION_AUTHORITY,
|
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
} from "@vrtmrz/livesync-commonlib/replication";
|
} from "@vrtmrz/livesync-commonlib/replication";
|
||||||
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
|
||||||
import { isRemoteAdministrationCommand, runRemoteAdministrationCommand } from "./remoteAdministration";
|
import {
|
||||||
|
isCentralRemoteAdministrationCommand,
|
||||||
|
runCentralRemoteAdministrationCommand,
|
||||||
|
} from "./centralRemoteAdministration";
|
||||||
|
|
||||||
function redactConnectionString(uri: string): string {
|
function redactConnectionString(uri: string): string {
|
||||||
return uri.replace(/\/\/([^@/]+)@/u, "//***@");
|
return uri.replace(/\/\/([^@/]+)@/u, "//***@");
|
||||||
@@ -175,8 +179,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
|||||||
// TODO: Standardise the logic for identifying the cause of replication
|
// TODO: Standardise the logic for identifying the cause of replication
|
||||||
// failure so that every reason (locked DB, version mismatch, network
|
// failure so that every reason (locked DB, version mismatch, network
|
||||||
// error, etc.) is surfaced with a CLI-specific actionable message.
|
// error, etc.) is surfaced with a CLI-specific actionable message.
|
||||||
const replicator = core.services.replicator.getActiveReplicator();
|
const recoveryHint = result.status === "failed" ? result.recoveryHint : undefined;
|
||||||
if (replicator?.remoteLockedAndDeviceNotAccepted) {
|
if (
|
||||||
|
recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED ||
|
||||||
|
recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED
|
||||||
|
) {
|
||||||
writeStderrLine(
|
writeStderrLine(
|
||||||
standardIo,
|
standardIo,
|
||||||
`[Error] The remote database is locked and this device is not yet accepted.\n` +
|
`[Error] The remote database is locked and this device is not yet accepted.\n` +
|
||||||
@@ -723,8 +730,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRemoteAdministrationCommand(options.command)) {
|
if (isCentralRemoteAdministrationCommand(options.command)) {
|
||||||
return await runRemoteAdministrationCommand(options, context, options.command);
|
return await runCentralRemoteAdministrationCommand(options, context, options.command);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.command === "remote-status") {
|
if (options.command === "remote-status") {
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|||||||
import { runCommand } from "./runCommand";
|
import { runCommand } from "./runCommand";
|
||||||
import type { CLIOptions } from "./types";
|
import type { CLIOptions } from "./types";
|
||||||
import {
|
import {
|
||||||
REMOTE_ADMINISTRATION_ACTIONS,
|
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||||
REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||||
REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||||
REMOTE_ADMINISTRATION_RESULT_STATUSES,
|
CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES,
|
||||||
REMOTE_RESOURCE_KINDS,
|
REMOTE_RESOURCE_KINDS,
|
||||||
|
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||||
|
REPLICATION_COMPLETED,
|
||||||
|
replicationFailed,
|
||||||
} from "@vrtmrz/livesync-commonlib/replication";
|
} from "@vrtmrz/livesync-commonlib/replication";
|
||||||
|
|
||||||
function createStandardIoMock() {
|
function createStandardIoMock() {
|
||||||
@@ -56,13 +59,14 @@ function createCoreMock() {
|
|||||||
markResolved: vi.fn(async () => {}),
|
markResolved: vi.fn(async () => {}),
|
||||||
markUnlocked: vi.fn(async () => {}),
|
markUnlocked: vi.fn(async () => {}),
|
||||||
markLocked: vi.fn(async () => {}),
|
markLocked: vi.fn(async () => {}),
|
||||||
|
replicateUserInitiated: vi.fn(async () => REPLICATION_COMPLETED),
|
||||||
},
|
},
|
||||||
replicator: {
|
replicator: {
|
||||||
runRemoteAdministration: vi.fn(async ({ action }) => ({
|
runCentralRemoteAdministration: vi.fn(async ({ action }) => ({
|
||||||
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
||||||
observation: {
|
observation: {
|
||||||
kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||||
locked: action === REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
locked: action === CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
nodeId: "test-node-id",
|
nodeId: "test-node-id",
|
||||||
},
|
},
|
||||||
@@ -261,6 +265,27 @@ describe("runCommand abnormal cases", () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports a lock from the exact sync outcome without inspecting a replacement Replicator", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
core.services.replication.replicateUserInitiated.mockResolvedValue(
|
||||||
|
replicationFailed(new Error("locked"), {
|
||||||
|
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runCommand(makeOptions("sync", []), {
|
||||||
|
...context,
|
||||||
|
core,
|
||||||
|
})
|
||||||
|
).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("remote database is locked")
|
||||||
|
);
|
||||||
|
expect(core.services.replicator.getActiveReplicator).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("pull returns false for non-existing path", async () => {
|
it("pull returns false for non-existing path", async () => {
|
||||||
const core = createCoreMock();
|
const core = createCoreMock();
|
||||||
core.serviceModules.fileHandler.dbToStorage.mockResolvedValue(false);
|
core.serviceModules.fileHandler.dbToStorage.mockResolvedValue(false);
|
||||||
@@ -736,11 +761,36 @@ describe("runCommand abnormal cases", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("mark-resolved and unlock-remote commands", () => {
|
describe("mark-resolved and unlock-remote commands", () => {
|
||||||
|
it("reports a connection failure without claiming that every central remote is CouchDB", async () => {
|
||||||
|
const core = createCoreMock();
|
||||||
|
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||||
|
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||||
|
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED,
|
||||||
|
detail: new Error("remote unavailable"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||||
|
...context,
|
||||||
|
core,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
const verificationOutput = core.services.context.standardIo.writeStderr.mock.calls
|
||||||
|
.map(([chunk]: [string | Uint8Array]) =>
|
||||||
|
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
expect(verificationOutput).toContain(
|
||||||
|
"[Verification] Failed to connect to the configured remote: remote unavailable\n"
|
||||||
|
);
|
||||||
|
expect(verificationOutput).not.toContain("CouchDB");
|
||||||
|
});
|
||||||
|
|
||||||
it("fails by default when remote administration cannot verify its postcondition", async () => {
|
it("fails by default when remote administration cannot verify its postcondition", async () => {
|
||||||
const core = createCoreMock();
|
const core = createCoreMock();
|
||||||
core.services.replicator.runRemoteAdministration.mockResolvedValueOnce({
|
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||||
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||||
reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
|
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runCommand(makeOptions("mark-resolved", []), {
|
const result = await runCommand(makeOptions("mark-resolved", []), {
|
||||||
@@ -753,9 +803,9 @@ describe("runCommand abnormal cases", () => {
|
|||||||
|
|
||||||
it("preserves the historical zero exit for returned verification failures only when requested", async () => {
|
it("preserves the historical zero exit for returned verification failures only when requested", async () => {
|
||||||
const core = createCoreMock();
|
const core = createCoreMock();
|
||||||
core.services.replicator.runRemoteAdministration.mockResolvedValueOnce({
|
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||||
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||||
reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
|
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runCommand(
|
const result = await runCommand(
|
||||||
@@ -772,7 +822,7 @@ describe("runCommand abnormal cases", () => {
|
|||||||
it("does not hide a thrown remote mutation failure behind the compatibility option", async () => {
|
it("does not hide a thrown remote mutation failure behind the compatibility option", async () => {
|
||||||
const core = createCoreMock();
|
const core = createCoreMock();
|
||||||
const failure = new Error("mutation failed");
|
const failure = new Error("mutation failed");
|
||||||
core.services.replicator.runRemoteAdministration.mockRejectedValueOnce(failure);
|
core.services.replicator.runCentralRemoteAdministration.mockRejectedValueOnce(failure);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runCommand(
|
runCommand(
|
||||||
@@ -797,16 +847,16 @@ describe("runCommand abnormal cases", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
expect(core.services.replicator.runRemoteAdministration).not.toHaveBeenCalled();
|
expect(core.services.replicator.runCentralRemoteAdministration).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails a lock command when the observed milestone remains unlocked", async () => {
|
it("fails a lock command when the observed milestone remains unlocked", async () => {
|
||||||
const core = createCoreMock();
|
const core = createCoreMock();
|
||||||
core.services.replicator.runRemoteAdministration.mockResolvedValueOnce({
|
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
|
||||||
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||||
reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
||||||
observation: {
|
observation: {
|
||||||
kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||||
locked: false,
|
locked: false,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
nodeId: "test-node-id",
|
nodeId: "test-node-id",
|
||||||
@@ -835,8 +885,8 @@ describe("runCommand abnormal cases", () => {
|
|||||||
core,
|
core,
|
||||||
});
|
});
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
|
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||||
action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||||
});
|
});
|
||||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||||
expect(core.services.replication.markResolved).not.toHaveBeenCalled();
|
expect(core.services.replication.markResolved).not.toHaveBeenCalled();
|
||||||
@@ -857,8 +907,8 @@ describe("runCommand abnormal cases", () => {
|
|||||||
core,
|
core,
|
||||||
});
|
});
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
|
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||||
action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||||
});
|
});
|
||||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||||
expect(settings.activeConfigurationId).toBe("r1");
|
expect(settings.activeConfigurationId).toBe("r1");
|
||||||
@@ -872,8 +922,8 @@ describe("runCommand abnormal cases", () => {
|
|||||||
core,
|
core,
|
||||||
});
|
});
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
|
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||||
action: REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||||
});
|
});
|
||||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -893,8 +943,8 @@ describe("runCommand abnormal cases", () => {
|
|||||||
core,
|
core,
|
||||||
});
|
});
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
|
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||||
action: REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||||
});
|
});
|
||||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||||
expect(settings.activeConfigurationId).toBe("r1");
|
expect(settings.activeConfigurationId).toBe("r1");
|
||||||
@@ -908,8 +958,8 @@ describe("runCommand abnormal cases", () => {
|
|||||||
core,
|
core,
|
||||||
});
|
});
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
|
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||||
action: REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||||
});
|
});
|
||||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -929,8 +979,8 @@ describe("runCommand abnormal cases", () => {
|
|||||||
core,
|
core,
|
||||||
});
|
});
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
|
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
|
||||||
action: REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||||
});
|
});
|
||||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||||
expect(settings.activeConfigurationId).toBe("r1");
|
expect(settings.activeConfigurationId).toBe("r1");
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import {
|
||||||
|
MILESTONE_DOCID,
|
||||||
|
type EntryMilestoneInfo,
|
||||||
|
type RemoteDBSettings,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
|
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||||
|
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||||
|
import {
|
||||||
|
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||||
|
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||||
|
applyCentralRemoteAdministrationMutation,
|
||||||
|
milestoneSatisfiesCentralRemoteAdministration,
|
||||||
|
centralRemoteAdministrationVerificationFailed,
|
||||||
|
centralRemoteAdministrationVerified,
|
||||||
|
supportedCapability,
|
||||||
|
type MilestoneCentralRemoteAdministrationObservation,
|
||||||
|
type CentralRemoteAdministrationFailureReason,
|
||||||
|
type CentralRemoteAdministrationRequest,
|
||||||
|
type CentralRemoteAdministrationReplicator,
|
||||||
|
type CentralRemoteAdministrationResult,
|
||||||
|
type CentralRemoteAdministrationRunner,
|
||||||
|
type SupportedCapability,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/replication";
|
||||||
|
|
||||||
|
const JOURNAL_MILESTONE_PATH = "_00000000-milestone.json";
|
||||||
|
|
||||||
|
type CentralMilestoneReadResult =
|
||||||
|
| { readonly milestone: EntryMilestoneInfo | false | undefined }
|
||||||
|
| { readonly failureReason: CentralRemoteAdministrationFailureReason; readonly detail?: unknown };
|
||||||
|
|
||||||
|
type PreparedCentralMilestoneReader = () => Promise<CentralMilestoneReadResult>;
|
||||||
|
|
||||||
|
type CentralMilestoneReaderPreparer = (
|
||||||
|
replicator: CentralRemoteAdministrationReplicator,
|
||||||
|
setting: RemoteDBSettings
|
||||||
|
) => PreparedCentralMilestoneReader;
|
||||||
|
|
||||||
|
type CouchDBAdministrationReplicator = CentralRemoteAdministrationReplicator &
|
||||||
|
Pick<LiveSyncCouchDBReplicator, "connectRemoteCouchDBWithSetting" | "isMobile">;
|
||||||
|
|
||||||
|
type JournalAdministrationClient = Pick<LiveSyncJournalReplicator["client"], "downloadJson">;
|
||||||
|
|
||||||
|
async function ensureLocalNodeIdentity(
|
||||||
|
replicator: CentralRemoteAdministrationReplicator
|
||||||
|
): Promise<CentralRemoteAdministrationResult | undefined> {
|
||||||
|
if (replicator.nodeid) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if ((await replicator.initializeDatabaseForReplication()) && replicator.nodeid) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return centralRemoteAdministrationVerificationFailed(
|
||||||
|
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeMilestone(
|
||||||
|
replicator: CentralRemoteAdministrationReplicator,
|
||||||
|
milestone: EntryMilestoneInfo
|
||||||
|
): MilestoneCentralRemoteAdministrationObservation {
|
||||||
|
return {
|
||||||
|
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||||
|
locked: !!milestone.locked,
|
||||||
|
accepted: !!milestone.accepted_nodes?.includes(replicator.nodeid),
|
||||||
|
nodeId: replicator.nodeid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultFromMilestone(
|
||||||
|
replicator: CentralRemoteAdministrationReplicator,
|
||||||
|
request: CentralRemoteAdministrationRequest,
|
||||||
|
milestone: EntryMilestoneInfo | false | undefined
|
||||||
|
): CentralRemoteAdministrationResult {
|
||||||
|
if (!milestone) {
|
||||||
|
return centralRemoteAdministrationVerificationFailed(
|
||||||
|
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const observation = observeMilestone(replicator, milestone);
|
||||||
|
return milestoneSatisfiesCentralRemoteAdministration(request.action, observation)
|
||||||
|
? centralRemoteAdministrationVerified(observation)
|
||||||
|
: centralRemoteAdministrationVerificationFailed(
|
||||||
|
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
||||||
|
{
|
||||||
|
observation,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply and verify the central milestone protocol without selecting a provider.
|
||||||
|
*
|
||||||
|
* The provider definition has already selected the reader preparer. Preparing
|
||||||
|
* it before mutation rejects incomplete composition before a remote write and
|
||||||
|
* binds any provider-owned client which must be used for postcondition reading.
|
||||||
|
*/
|
||||||
|
async function runCentralRemoteAdministration(
|
||||||
|
replicator: CentralRemoteAdministrationReplicator,
|
||||||
|
setting: RemoteDBSettings,
|
||||||
|
request: CentralRemoteAdministrationRequest,
|
||||||
|
prepareMilestoneReader: CentralMilestoneReaderPreparer
|
||||||
|
): Promise<CentralRemoteAdministrationResult> {
|
||||||
|
const identityFailure = await ensureLocalNodeIdentity(replicator);
|
||||||
|
if (identityFailure) return identityFailure;
|
||||||
|
|
||||||
|
const readMilestone = prepareMilestoneReader(replicator, setting);
|
||||||
|
await applyCentralRemoteAdministrationMutation(replicator, setting, request.action);
|
||||||
|
|
||||||
|
const readResult = await readMilestone();
|
||||||
|
if ("failureReason" in readResult) {
|
||||||
|
return centralRemoteAdministrationVerificationFailed(readResult.failureReason, { detail: readResult.detail });
|
||||||
|
}
|
||||||
|
return resultFromMilestone(replicator, request, readResult.milestone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireCouchDBAdministrationOperations(
|
||||||
|
replicator: CentralRemoteAdministrationReplicator
|
||||||
|
): asserts replicator is CouchDBAdministrationReplicator {
|
||||||
|
const candidate = replicator as Partial<CouchDBAdministrationReplicator>;
|
||||||
|
if (typeof candidate.connectRemoteCouchDBWithSetting !== "function" || typeof candidate.isMobile !== "function") {
|
||||||
|
throw new Error("The configured CouchDB administration adapter does not provide milestone access.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareCouchDBMilestoneReader(
|
||||||
|
replicator: CentralRemoteAdministrationReplicator,
|
||||||
|
setting: RemoteDBSettings
|
||||||
|
): PreparedCentralMilestoneReader {
|
||||||
|
requireCouchDBAdministrationOperations(replicator);
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
let connection: Awaited<ReturnType<CouchDBAdministrationReplicator["connectRemoteCouchDBWithSetting"]>>;
|
||||||
|
try {
|
||||||
|
connection = await replicator.connectRemoteCouchDBWithSetting(setting, replicator.isMobile(), true);
|
||||||
|
} catch (error) {
|
||||||
|
return { failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED, detail: error };
|
||||||
|
}
|
||||||
|
if (typeof connection === "string") {
|
||||||
|
return {
|
||||||
|
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED,
|
||||||
|
detail: connection,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let milestone: EntryMilestoneInfo | undefined;
|
||||||
|
let observationError: unknown;
|
||||||
|
try {
|
||||||
|
milestone = await connection.db.get<EntryMilestoneInfo>(MILESTONE_DOCID);
|
||||||
|
} catch (error) {
|
||||||
|
observationError = error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await connection.close();
|
||||||
|
} catch (error) {
|
||||||
|
observationError ??= error;
|
||||||
|
}
|
||||||
|
if (observationError !== undefined) {
|
||||||
|
return {
|
||||||
|
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
|
||||||
|
detail: observationError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { milestone };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireJournalAdministrationClient(
|
||||||
|
replicator: CentralRemoteAdministrationReplicator
|
||||||
|
): JournalAdministrationClient {
|
||||||
|
const client = (replicator as { readonly client?: JournalAdministrationClient }).client;
|
||||||
|
if (typeof client?.downloadJson !== "function") {
|
||||||
|
throw new Error("The configured Object Storage administration adapter does not provide milestone access.");
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareObjectStorageMilestoneReader(
|
||||||
|
replicator: CentralRemoteAdministrationReplicator
|
||||||
|
): PreparedCentralMilestoneReader {
|
||||||
|
const client = requireJournalAdministrationClient(replicator);
|
||||||
|
|
||||||
|
return async () => {
|
||||||
|
try {
|
||||||
|
return { milestone: await client.downloadJson<EntryMilestoneInfo>(JOURNAL_MILESTONE_PATH) };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
|
||||||
|
detail: error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const runCouchDBCentralRemoteAdministration: CentralRemoteAdministrationRunner = async (replicator, setting, request) =>
|
||||||
|
await runCentralRemoteAdministration(replicator, setting, request, prepareCouchDBMilestoneReader);
|
||||||
|
|
||||||
|
const runObjectStorageCentralRemoteAdministration: CentralRemoteAdministrationRunner = async (
|
||||||
|
replicator,
|
||||||
|
setting,
|
||||||
|
request
|
||||||
|
) => await runCentralRemoteAdministration(replicator, setting, request, prepareObjectStorageMilestoneReader);
|
||||||
|
|
||||||
|
/** CouchDB mutation and milestone postcondition verification capability. */
|
||||||
|
export const COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability<CentralRemoteAdministrationRunner> =
|
||||||
|
supportedCapability(runCouchDBCentralRemoteAdministration);
|
||||||
|
|
||||||
|
/** Object Storage mutation and milestone postcondition verification capability. */
|
||||||
|
export const OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability<CentralRemoteAdministrationRunner> =
|
||||||
|
supportedCapability(runObjectStorageCentralRemoteAdministration);
|
||||||
+67
-26
@@ -1,15 +1,15 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import {
|
import {
|
||||||
REMOTE_ADMINISTRATION_ACTIONS,
|
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
|
||||||
REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
||||||
REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
||||||
REMOTE_ADMINISTRATION_RESULT_STATUSES,
|
CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES,
|
||||||
} from "@vrtmrz/livesync-commonlib/replication";
|
} from "@vrtmrz/livesync-commonlib/replication";
|
||||||
import {
|
import {
|
||||||
COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY,
|
COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||||
OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY,
|
OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||||
} from "./replicatorAdministration";
|
} from "./centralRemoteAdministration";
|
||||||
|
|
||||||
describe("central remote administration capabilities", () => {
|
describe("central remote administration capabilities", () => {
|
||||||
it("mutates CouchDB, verifies the requested postcondition, and closes only the owned connection", async () => {
|
it("mutates CouchDB, verifies the requested postcondition, and closes only the owned connection", async () => {
|
||||||
@@ -28,14 +28,14 @@ describe("central remote administration capabilities", () => {
|
|||||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: database, close })),
|
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: database, close })),
|
||||||
};
|
};
|
||||||
const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB };
|
const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB };
|
||||||
const capability = COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY;
|
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
capability.run(replicator as never, setting, { action: REMOTE_ADMINISTRATION_ACTIONS.LOCK })
|
capability.run(replicator as never, setting, { action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK })
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
||||||
observation: {
|
observation: {
|
||||||
kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||||
locked: true,
|
locked: true,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
nodeId: "node-1",
|
nodeId: "node-1",
|
||||||
@@ -60,20 +60,20 @@ describe("central remote administration capabilities", () => {
|
|||||||
close,
|
close,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const capability = COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY;
|
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||||
|
|
||||||
const result = await capability.run(
|
const result = await capability.run(
|
||||||
replicator as never,
|
replicator as never,
|
||||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||||
{
|
{
|
||||||
action: REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||||
reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
|
||||||
observation: { kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, locked: false },
|
observation: { kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE, locked: false },
|
||||||
});
|
});
|
||||||
expect(close).toHaveBeenCalledOnce();
|
expect(close).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
@@ -87,17 +87,17 @@ describe("central remote administration capabilities", () => {
|
|||||||
markRemoteResolved: vi.fn(async () => undefined),
|
markRemoteResolved: vi.fn(async () => undefined),
|
||||||
connectRemoteCouchDBWithSetting: vi.fn(async () => "must not connect"),
|
connectRemoteCouchDBWithSetting: vi.fn(async () => "must not connect"),
|
||||||
};
|
};
|
||||||
const capability = COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY;
|
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
capability.run(
|
capability.run(
|
||||||
replicator as never,
|
replicator as never,
|
||||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||||
{ action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
|
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
|
||||||
)
|
)
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
|
||||||
reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE,
|
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE,
|
||||||
});
|
});
|
||||||
expect(replicator.markRemoteResolved).not.toHaveBeenCalled();
|
expect(replicator.markRemoteResolved).not.toHaveBeenCalled();
|
||||||
expect(replicator.connectRemoteCouchDBWithSetting).not.toHaveBeenCalled();
|
expect(replicator.connectRemoteCouchDBWithSetting).not.toHaveBeenCalled();
|
||||||
@@ -115,14 +115,14 @@ describe("central remote administration capabilities", () => {
|
|||||||
markRemoteResolved: vi.fn(async () => undefined),
|
markRemoteResolved: vi.fn(async () => undefined),
|
||||||
connectRemoteCouchDBWithSetting: vi.fn(),
|
connectRemoteCouchDBWithSetting: vi.fn(),
|
||||||
};
|
};
|
||||||
const capability = COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY;
|
const capability = COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
capability.run(
|
capability.run(
|
||||||
replicator as never,
|
replicator as never,
|
||||||
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||||
{
|
{
|
||||||
action: REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
).rejects.toBe(failure);
|
).rejects.toBe(failure);
|
||||||
@@ -139,14 +139,16 @@ describe("central remote administration capabilities", () => {
|
|||||||
client: { downloadJson },
|
client: { downloadJson },
|
||||||
};
|
};
|
||||||
const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO };
|
const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO };
|
||||||
const capability = OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY;
|
const capability = OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
capability.run(replicator as never, setting, { action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED })
|
capability.run(replicator as never, setting, {
|
||||||
|
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
|
||||||
|
})
|
||||||
).resolves.toEqual({
|
).resolves.toEqual({
|
||||||
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
|
||||||
observation: {
|
observation: {
|
||||||
kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
||||||
locked: false,
|
locked: false,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
nodeId: "node-1",
|
nodeId: "node-1",
|
||||||
@@ -155,4 +157,43 @@ describe("central remote administration capabilities", () => {
|
|||||||
expect(replicator.markRemoteResolved).toHaveBeenCalledWith(setting);
|
expect(replicator.markRemoteResolved).toHaveBeenCalledWith(setting);
|
||||||
expect(downloadJson).toHaveBeenCalledWith("_00000000-milestone.json");
|
expect(downloadJson).toHaveBeenCalledWith("_00000000-milestone.json");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects an incomplete CouchDB milestone adapter before mutation", async () => {
|
||||||
|
const markRemoteLocked = vi.fn(async () => undefined);
|
||||||
|
const replicator = {
|
||||||
|
nodeid: "node-1",
|
||||||
|
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||||
|
markRemoteLocked,
|
||||||
|
markRemoteResolved: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
|
||||||
|
replicator as never,
|
||||||
|
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_COUCHDB },
|
||||||
|
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK }
|
||||||
|
)
|
||||||
|
).rejects.toThrow("The configured CouchDB administration adapter does not provide milestone access.");
|
||||||
|
expect(markRemoteLocked).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an incomplete Object Storage milestone adapter before mutation", async () => {
|
||||||
|
const markRemoteResolved = vi.fn(async () => undefined);
|
||||||
|
const replicator = {
|
||||||
|
nodeid: "node-1",
|
||||||
|
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||||
|
markRemoteLocked: vi.fn(async () => undefined),
|
||||||
|
markRemoteResolved,
|
||||||
|
client: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
|
||||||
|
replicator as never,
|
||||||
|
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO },
|
||||||
|
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
|
||||||
|
)
|
||||||
|
).rejects.toThrow("The configured Object Storage administration adapter does not provide milestone access.");
|
||||||
|
expect(markRemoteResolved).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import {
|
|
||||||
MILESTONE_DOCID,
|
|
||||||
type EntryMilestoneInfo,
|
|
||||||
type RemoteDBSettings,
|
|
||||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
|
||||||
import type { LiveSyncAbstractReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/LiveSyncAbstractReplicator";
|
|
||||||
import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
|
||||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
|
||||||
import {
|
|
||||||
REMOTE_ADMINISTRATION_FAILURE_REASONS,
|
|
||||||
REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
|
|
||||||
applyRemoteAdministrationMutation,
|
|
||||||
milestoneSatisfiesRemoteAdministration,
|
|
||||||
remoteAdministrationVerificationFailed,
|
|
||||||
remoteAdministrationVerified,
|
|
||||||
supportedCapability,
|
|
||||||
type MilestoneRemoteAdministrationObservation,
|
|
||||||
type RemoteAdministrationRequest,
|
|
||||||
type RemoteAdministrationResult,
|
|
||||||
type SupportedCapability,
|
|
||||||
type RemoteAdministrationRunner,
|
|
||||||
} from "@vrtmrz/livesync-commonlib/replication";
|
|
||||||
|
|
||||||
const JOURNAL_MILESTONE_PATH = "_00000000-milestone.json";
|
|
||||||
|
|
||||||
async function ensureLocalNodeIdentity(
|
|
||||||
replicator: LiveSyncAbstractReplicator
|
|
||||||
): Promise<RemoteAdministrationResult | undefined> {
|
|
||||||
if (replicator.nodeid) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if ((await replicator.initializeDatabaseForReplication()) && replicator.nodeid) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.LOCAL_IDENTITY_UNAVAILABLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
function observeMilestone(
|
|
||||||
replicator: LiveSyncAbstractReplicator,
|
|
||||||
milestone: EntryMilestoneInfo
|
|
||||||
): MilestoneRemoteAdministrationObservation {
|
|
||||||
return {
|
|
||||||
kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
|
|
||||||
locked: !!milestone.locked,
|
|
||||||
accepted: !!milestone.accepted_nodes?.includes(replicator.nodeid),
|
|
||||||
nodeId: replicator.nodeid,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function resultFromMilestone(
|
|
||||||
replicator: LiveSyncAbstractReplicator,
|
|
||||||
request: RemoteAdministrationRequest,
|
|
||||||
milestone: EntryMilestoneInfo | false | undefined
|
|
||||||
): RemoteAdministrationResult {
|
|
||||||
if (!milestone) {
|
|
||||||
return remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND);
|
|
||||||
}
|
|
||||||
const observation = observeMilestone(replicator, milestone);
|
|
||||||
return milestoneSatisfiesRemoteAdministration(request.action, observation)
|
|
||||||
? remoteAdministrationVerified(observation)
|
|
||||||
: remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH, {
|
|
||||||
observation,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runCouchDBRemoteAdministration(
|
|
||||||
replicator: LiveSyncAbstractReplicator,
|
|
||||||
setting: RemoteDBSettings,
|
|
||||||
request: RemoteAdministrationRequest
|
|
||||||
): Promise<RemoteAdministrationResult> {
|
|
||||||
const identityFailure = await ensureLocalNodeIdentity(replicator);
|
|
||||||
if (identityFailure) return identityFailure;
|
|
||||||
|
|
||||||
await applyRemoteAdministrationMutation(replicator, setting, request.action);
|
|
||||||
|
|
||||||
const couchDBReplicator = replicator as LiveSyncCouchDBReplicator;
|
|
||||||
let connection;
|
|
||||||
try {
|
|
||||||
connection = await couchDBReplicator.connectRemoteCouchDBWithSetting(
|
|
||||||
setting,
|
|
||||||
couchDBReplicator.isMobile(),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED, {
|
|
||||||
detail: error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (typeof connection === "string") {
|
|
||||||
return remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED, {
|
|
||||||
detail: connection,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let milestone: EntryMilestoneInfo | undefined;
|
|
||||||
let observationError: unknown;
|
|
||||||
try {
|
|
||||||
milestone = await connection.db.get<EntryMilestoneInfo>(MILESTONE_DOCID);
|
|
||||||
} catch (error) {
|
|
||||||
observationError = error;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await connection.close();
|
|
||||||
} catch (error) {
|
|
||||||
observationError ??= error;
|
|
||||||
}
|
|
||||||
if (observationError !== undefined) {
|
|
||||||
return remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED, {
|
|
||||||
detail: observationError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return resultFromMilestone(replicator, request, milestone);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runObjectStorageRemoteAdministration(
|
|
||||||
replicator: LiveSyncAbstractReplicator,
|
|
||||||
setting: RemoteDBSettings,
|
|
||||||
request: RemoteAdministrationRequest
|
|
||||||
): Promise<RemoteAdministrationResult> {
|
|
||||||
const identityFailure = await ensureLocalNodeIdentity(replicator);
|
|
||||||
if (identityFailure) return identityFailure;
|
|
||||||
|
|
||||||
await applyRemoteAdministrationMutation(replicator, setting, request.action);
|
|
||||||
|
|
||||||
const journalReplicator = replicator as LiveSyncJournalReplicator;
|
|
||||||
let milestone: EntryMilestoneInfo | false | undefined;
|
|
||||||
try {
|
|
||||||
milestone = await journalReplicator.client.downloadJson<EntryMilestoneInfo>(JOURNAL_MILESTONE_PATH);
|
|
||||||
} catch (error) {
|
|
||||||
return remoteAdministrationVerificationFailed(REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED, {
|
|
||||||
detail: error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return resultFromMilestone(replicator, request, milestone);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** CouchDB mutation and milestone postcondition verification capability. */
|
|
||||||
export const COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability<RemoteAdministrationRunner> =
|
|
||||||
supportedCapability(runCouchDBRemoteAdministration);
|
|
||||||
|
|
||||||
/** Object Storage mutation and milestone postcondition verification capability. */
|
|
||||||
export const OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY: SupportedCapability<RemoteAdministrationRunner> =
|
|
||||||
supportedCapability(runObjectStorageRemoteAdministration);
|
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
import { REMOTE_COUCHDB, REMOTE_MINIO, type RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import {
|
import {
|
||||||
CAPABILITY_NOT_APPLICABLE,
|
CAPABILITY_NOT_APPLICABLE,
|
||||||
CENTRAL_REMOTE_REPLICATION_READINESS,
|
CENTRAL_REMOTE_REPLICATION_READINESS,
|
||||||
|
NO_INTERACTION,
|
||||||
REMOTE_RESOURCE_KINDS,
|
REMOTE_RESOURCE_KINDS,
|
||||||
REPLACE_SAME_KIND_REPLICATOR,
|
|
||||||
defineReplicatorProviderDefinitions,
|
defineReplicatorProviderDefinitions,
|
||||||
supportedOpenReplicationContinuous,
|
supportedOpenReplicationContinuous,
|
||||||
supportedOpenReplicationOneShot,
|
replicationBlocked,
|
||||||
supportedOpenReplicationUnattended,
|
replicationFailed,
|
||||||
supportedStopActiveTransfer,
|
supportedStopActiveTransfer,
|
||||||
supportedCapability,
|
supportedCapability,
|
||||||
type ReplicatorProviderDefinitionMap,
|
type ReplicatorProviderDefinitionMap,
|
||||||
|
type ReplicationOutcome,
|
||||||
|
type ReplicatorInstance,
|
||||||
|
type UserInitiatedOneShotRunner,
|
||||||
|
type UnattendedOneShotRunner,
|
||||||
} from "@vrtmrz/livesync-commonlib/replication";
|
} from "@vrtmrz/livesync-commonlib/replication";
|
||||||
import {
|
import {
|
||||||
LiveSyncCouchDBReplicator,
|
LiveSyncCouchDBReplicator,
|
||||||
@@ -32,12 +36,62 @@ import {
|
|||||||
createObjectStorageSecuritySeedResourceFactory,
|
createObjectStorageSecuritySeedResourceFactory,
|
||||||
} from "./replicatorResources";
|
} from "./replicatorResources";
|
||||||
import {
|
import {
|
||||||
COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY,
|
COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||||
OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY,
|
OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||||
} from "./replicatorAdministration";
|
} from "./centralRemoteAdministration";
|
||||||
|
|
||||||
export type CentralReplicatorProviderHost = LiveSyncCouchDBReplicatorEnv & LiveSyncJournalReplicatorEnv;
|
export type CentralReplicatorProviderHost = LiveSyncCouchDBReplicatorEnv & LiveSyncJournalReplicatorEnv;
|
||||||
|
|
||||||
|
/** Minimal operation required by both central one-shot adapters. */
|
||||||
|
interface OneShotOutcomeReplicator extends ReplicatorInstance {
|
||||||
|
openOneShotReplicationWithOutcome(setting: RemoteDBSettings, showResult: boolean): Promise<ReplicationOutcome>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asOneShotOutcomeReplicator(instance: ReplicatorInstance): OneShotOutcomeReplicator | undefined {
|
||||||
|
const candidate = instance as Partial<OneShotOutcomeReplicator>;
|
||||||
|
return typeof candidate.openOneShotReplicationWithOutcome === "function"
|
||||||
|
? (instance as OneShotOutcomeReplicator)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOneShotWithOutcome(
|
||||||
|
instance: ReplicatorInstance,
|
||||||
|
setting: RemoteDBSettings,
|
||||||
|
showResult: boolean
|
||||||
|
): Promise<ReplicationOutcome> {
|
||||||
|
const replicator = asOneShotOutcomeReplicator(instance);
|
||||||
|
if (!replicator) {
|
||||||
|
return replicationFailed(new Error("The configured provider does not implement one-shot replication."));
|
||||||
|
}
|
||||||
|
return await replicator.openOneShotReplicationWithOutcome(setting, showResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||||
|
return await runOneShotWithOutcome(
|
||||||
|
instance,
|
||||||
|
setting,
|
||||||
|
request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const couchDBUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||||
|
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||||
|
return await runOneShotWithOutcome(instance, setting, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const objectStorageUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
|
||||||
|
return await runOneShotWithOutcome(
|
||||||
|
instance,
|
||||||
|
setting,
|
||||||
|
request.interaction.kind === "permitted" && request.interaction.permissions.failureRecovery
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const objectStorageUnattendedOneShot: UnattendedOneShotRunner = async (instance, setting, request) => {
|
||||||
|
if (request.interaction.kind !== NO_INTERACTION.kind) return replicationBlocked("interaction-required");
|
||||||
|
return await runOneShotWithOutcome(instance, setting, false);
|
||||||
|
};
|
||||||
|
|
||||||
/** Build the complete central-remote provider policy for one LiveSync host. */
|
/** Build the complete central-remote provider policy for one LiveSync host. */
|
||||||
export function createCentralReplicatorProviderDefinitions(
|
export function createCentralReplicatorProviderDefinitions(
|
||||||
host: CentralReplicatorProviderHost
|
host: CentralReplicatorProviderHost
|
||||||
@@ -52,7 +106,6 @@ export function createCentralReplicatorProviderDefinitions(
|
|||||||
!!settings.couchDB_URI?.trim() &&
|
!!settings.couchDB_URI?.trim() &&
|
||||||
!!settings.couchDB_DBNAME?.trim(),
|
!!settings.couchDB_DBNAME?.trim(),
|
||||||
configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
|
configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
|
||||||
sameKindReconciliation: REPLACE_SAME_KIND_REPLICATOR,
|
|
||||||
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
|
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
|
||||||
remoteResources: {
|
remoteResources: {
|
||||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
|
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
|
||||||
@@ -66,9 +119,9 @@ export function createCentralReplicatorProviderDefinitions(
|
|||||||
createCouchDBSynchronisationInformationResourceFactory(host)
|
createCouchDBSynchronisationInformationResourceFactory(host)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
remoteAdministration: COUCHDB_REMOTE_ADMINISTRATION_CAPABILITY,
|
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||||
userInitiatedOneShot: supportedOpenReplicationOneShot(),
|
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
|
||||||
unattendedOneShot: supportedOpenReplicationUnattended(),
|
unattendedOneShot: supportedCapability(couchDBUnattendedOneShot),
|
||||||
continuous: supportedOpenReplicationContinuous(),
|
continuous: supportedOpenReplicationContinuous(),
|
||||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||||
},
|
},
|
||||||
@@ -79,7 +132,6 @@ export function createCentralReplicatorProviderDefinitions(
|
|||||||
isConfigured: (settings) =>
|
isConfigured: (settings) =>
|
||||||
settings.remoteType === REMOTE_MINIO && !!settings.endpoint?.trim() && !!settings.bucket?.trim(),
|
settings.remoteType === REMOTE_MINIO && !!settings.endpoint?.trim() && !!settings.bucket?.trim(),
|
||||||
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
|
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
|
||||||
sameKindReconciliation: REPLACE_SAME_KIND_REPLICATOR,
|
|
||||||
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
|
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
|
||||||
remoteResources: {
|
remoteResources: {
|
||||||
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
|
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
|
||||||
@@ -93,9 +145,9 @@ export function createCentralReplicatorProviderDefinitions(
|
|||||||
),
|
),
|
||||||
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE,
|
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE,
|
||||||
},
|
},
|
||||||
remoteAdministration: OBJECT_STORAGE_REMOTE_ADMINISTRATION_CAPABILITY,
|
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
|
||||||
userInitiatedOneShot: supportedOpenReplicationOneShot(),
|
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
|
||||||
unattendedOneShot: supportedOpenReplicationUnattended(),
|
unattendedOneShot: supportedCapability(objectStorageUnattendedOneShot),
|
||||||
continuous: CAPABILITY_NOT_APPLICABLE,
|
continuous: CAPABILITY_NOT_APPLICABLE,
|
||||||
stopActiveTransfer: supportedStopActiveTransfer(),
|
stopActiveTransfer: supportedStopActiveTransfer(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/
|
|||||||
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
|
||||||
import {
|
import {
|
||||||
CAPABILITY_SUPPORT_KINDS,
|
CAPABILITY_SUPPORT_KINDS,
|
||||||
|
NO_INTERACTION,
|
||||||
|
REPLICATION_COMPLETED,
|
||||||
REMOTE_RESOURCE_KINDS,
|
REMOTE_RESOURCE_KINDS,
|
||||||
REPLACE_SAME_KIND_REPLICATOR,
|
USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
} from "@vrtmrz/livesync-commonlib/replication";
|
} from "@vrtmrz/livesync-commonlib/replication";
|
||||||
|
|
||||||
const constructorMocks = vi.hoisted(() => ({
|
const constructorMocks = vi.hoisted(() => ({
|
||||||
couchDB: vi.fn(),
|
couchDB: vi.fn(),
|
||||||
|
couchDBOneShot: vi.fn(async (..._args: unknown[]) => REPLICATION_COMPLETED),
|
||||||
objectStorage: vi.fn(),
|
objectStorage: vi.fn(),
|
||||||
|
objectStorageOneShot: vi.fn(async (..._args: unknown[]) => REPLICATION_COMPLETED),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({
|
||||||
@@ -17,6 +21,9 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicato
|
|||||||
constructor(host: unknown) {
|
constructor(host: unknown) {
|
||||||
constructorMocks.couchDB(host);
|
constructorMocks.couchDB(host);
|
||||||
}
|
}
|
||||||
|
openOneShotReplicationWithOutcome(...args: unknown[]) {
|
||||||
|
return constructorMocks.couchDBOneShot(...args);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({
|
vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator", () => ({
|
||||||
@@ -24,12 +31,21 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
|
|||||||
constructor(host: unknown) {
|
constructor(host: unknown) {
|
||||||
constructorMocks.objectStorage(host);
|
constructorMocks.objectStorage(host);
|
||||||
}
|
}
|
||||||
|
openOneShotReplicationWithOutcome(...args: unknown[]) {
|
||||||
|
return constructorMocks.objectStorageOneShot(...args);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { createCentralReplicatorProviderDefinitions } from "./replicatorProviders";
|
import { createCentralReplicatorProviderDefinitions } from "./replicatorProviders";
|
||||||
|
|
||||||
describe("central Replicator provider definitions", () => {
|
describe("central Replicator provider definitions", () => {
|
||||||
|
it("keeps the retained remote-resource catalogue bounded", () => {
|
||||||
|
expect
|
||||||
|
.soft(Object.values(REMOTE_RESOURCE_KINDS).sort())
|
||||||
|
.toEqual(["connection", "preferred-tweak", "security-seed", "synchronisation-information"].sort());
|
||||||
|
});
|
||||||
|
|
||||||
it("composes CouchDB and Object Storage policies outside LiveSyncBaseCore", async () => {
|
it("composes CouchDB and Object Storage policies outside LiveSyncBaseCore", async () => {
|
||||||
const host = {} as Parameters<typeof createCentralReplicatorProviderDefinitions>[0];
|
const host = {} as Parameters<typeof createCentralReplicatorProviderDefinitions>[0];
|
||||||
const definitions = createCentralReplicatorProviderDefinitions(host);
|
const definitions = createCentralReplicatorProviderDefinitions(host);
|
||||||
@@ -37,8 +53,8 @@ describe("central Replicator provider definitions", () => {
|
|||||||
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||||
|
|
||||||
expect([...definitions.keys()]).toEqual([REMOTE_COUCHDB, REMOTE_MINIO]);
|
expect([...definitions.keys()]).toEqual([REMOTE_COUCHDB, REMOTE_MINIO]);
|
||||||
expect(couchDB.sameKindReconciliation).toBe(REPLACE_SAME_KIND_REPLICATOR);
|
expect("sameKindReconciliation" in couchDB).toBe(false);
|
||||||
expect(objectStorage.sameKindReconciliation).toBe(REPLACE_SAME_KIND_REPLICATOR);
|
expect("sameKindReconciliation" in objectStorage).toBe(false);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
couchDB.isConfigured(
|
couchDB.isConfigured(
|
||||||
@@ -76,10 +92,12 @@ describe("central Replicator provider definitions", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("declares an exhaustive resource and administration catalogue for both central providers", () => {
|
it("declares the retained owned resources and cohesive optional administration", () => {
|
||||||
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||||
const couchResources = definitions.get(REMOTE_COUCHDB)?.remoteResources;
|
const couchResources = definitions.get(REMOTE_COUCHDB)?.remoteResources;
|
||||||
const objectResources = definitions.get(REMOTE_MINIO)?.remoteResources;
|
const objectResources = definitions.get(REMOTE_MINIO)?.remoteResources;
|
||||||
|
const couchAdministration = definitions.get(REMOTE_COUCHDB)?.centralRemoteAdministration;
|
||||||
|
const objectAdministration = definitions.get(REMOTE_MINIO)?.centralRemoteAdministration;
|
||||||
|
|
||||||
expect(Object.keys(couchResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
expect(Object.keys(couchResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
||||||
expect(Object.keys(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
expect(Object.keys(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
|
||||||
@@ -93,7 +111,100 @@ describe("central Replicator provider definitions", () => {
|
|||||||
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
|
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
|
||||||
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
|
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
|
||||||
);
|
);
|
||||||
expect(definitions.get(REMOTE_COUCHDB)?.remoteAdministration.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
expect(couchAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||||
expect(definitions.get(REMOTE_MINIO)?.remoteAdministration.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
expect(objectAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
|
||||||
|
expect("activeRemoteReads" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
|
||||||
|
expect("fullTransfers" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches central finite work through provider-local attempt results", async () => {
|
||||||
|
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||||
|
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||||
|
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||||
|
const setting = createNewVaultSettings();
|
||||||
|
const couchInstance = await couchDB.create(setting);
|
||||||
|
const objectInstance = await objectStorage.create(setting);
|
||||||
|
if (!couchInstance || !objectInstance) throw new Error("Provider construction failed");
|
||||||
|
if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||||
|
throw new Error("CouchDB OneShot is unavailable");
|
||||||
|
}
|
||||||
|
if (objectStorage.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||||
|
throw new Error("Object Storage OneShot is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
couchDB.userInitiatedOneShot.run(couchInstance, setting, {
|
||||||
|
trigger: "manual",
|
||||||
|
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
|
})
|
||||||
|
).resolves.toBe(REPLICATION_COMPLETED);
|
||||||
|
await expect(
|
||||||
|
objectStorage.unattendedOneShot.run(objectInstance, setting, {
|
||||||
|
trigger: "resume",
|
||||||
|
interaction: NO_INTERACTION,
|
||||||
|
})
|
||||||
|
).resolves.toBe(REPLICATION_COMPLETED);
|
||||||
|
|
||||||
|
expect(constructorMocks.couchDBOneShot).toHaveBeenCalledWith(setting, true);
|
||||||
|
expect(constructorMocks.objectStorageOneShot).toHaveBeenCalledWith(setting, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches central finite work through the declared operation rather than constructor identity", async () => {
|
||||||
|
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||||
|
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||||
|
const objectStorage = definitions.get(REMOTE_MINIO)!;
|
||||||
|
const setting = createNewVaultSettings();
|
||||||
|
const createStructuralOneShotReplicator = () => ({
|
||||||
|
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||||
|
openReplication: vi.fn(async () => true),
|
||||||
|
terminateSync: vi.fn(),
|
||||||
|
closeReplication: vi.fn(),
|
||||||
|
openOneShotReplicationWithOutcome: vi.fn(async () => REPLICATION_COMPLETED),
|
||||||
|
});
|
||||||
|
const couchInstance = createStructuralOneShotReplicator();
|
||||||
|
const objectStorageInstance = createStructuralOneShotReplicator();
|
||||||
|
if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||||
|
throw new Error("CouchDB OneShot is unavailable");
|
||||||
|
}
|
||||||
|
if (objectStorage.unattendedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||||
|
throw new Error("Object Storage OneShot is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const couchOutcome = await couchDB.userInitiatedOneShot.run(couchInstance, setting, {
|
||||||
|
trigger: "manual",
|
||||||
|
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
|
});
|
||||||
|
const objectStorageOutcome = await objectStorage.unattendedOneShot.run(objectStorageInstance, setting, {
|
||||||
|
trigger: "resume",
|
||||||
|
interaction: NO_INTERACTION,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect.soft(couchOutcome).toBe(REPLICATION_COMPLETED);
|
||||||
|
expect.soft(objectStorageOutcome).toBe(REPLICATION_COMPLETED);
|
||||||
|
expect(couchInstance.openOneShotReplicationWithOutcome).toHaveBeenCalledWith(setting, true);
|
||||||
|
expect(objectStorageInstance.openOneShotReplicationWithOutcome).toHaveBeenCalledWith(setting, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a one-shot adapter whose Replicator does not declare the required operation", async () => {
|
||||||
|
const definitions = createCentralReplicatorProviderDefinitions({} as never);
|
||||||
|
const couchDB = definitions.get(REMOTE_COUCHDB)!;
|
||||||
|
const setting = createNewVaultSettings();
|
||||||
|
const incompleteInstance = {
|
||||||
|
initializeDatabaseForReplication: vi.fn(async () => true),
|
||||||
|
openReplication: vi.fn(async () => true),
|
||||||
|
terminateSync: vi.fn(),
|
||||||
|
closeReplication: vi.fn(),
|
||||||
|
};
|
||||||
|
if (couchDB.userInitiatedOneShot.kind !== CAPABILITY_SUPPORT_KINDS.SUPPORTED) {
|
||||||
|
throw new Error("CouchDB OneShot is unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await couchDB.userInitiatedOneShot.run(incompleteInstance, setting, {
|
||||||
|
trigger: "manual",
|
||||||
|
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outcome.status).toBe("failed");
|
||||||
|
expect(incompleteInstance.openReplication).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||||
import type { SecuritySeedResourceFactory } from "@vrtmrz/livesync-commonlib/replication";
|
import type { SecuritySeedResourceFactory } from "@vrtmrz/livesync-commonlib/replication";
|
||||||
import {
|
import {
|
||||||
LiveSyncCouchDBReplicator,
|
LiveSyncCouchDBReplicator,
|
||||||
@@ -5,12 +6,17 @@ import {
|
|||||||
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
|
||||||
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
|
||||||
import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicatorEnv";
|
import type { LiveSyncJournalReplicatorEnv } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicatorEnv";
|
||||||
import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared";
|
import { createReplicatorDisposer, snapshotRemoteSettings, type ResourceReplicator } from "./shared";
|
||||||
|
|
||||||
export type SecuritySeedResourceHost = LiveSyncCouchDBReplicatorEnv & LiveSyncJournalReplicatorEnv;
|
export type SecuritySeedResourceHost = LiveSyncCouchDBReplicatorEnv & LiveSyncJournalReplicatorEnv;
|
||||||
|
|
||||||
|
/** Minimal private Replicator surface required by a Security Seed resource. */
|
||||||
|
interface SecuritySeedReplicator extends ResourceReplicator {
|
||||||
|
getReplicationPBKDF2Salt(setting: RemoteDBSettings, refresh?: boolean): Promise<Uint8Array<ArrayBuffer>>;
|
||||||
|
}
|
||||||
|
|
||||||
function createSecuritySeedResourceFactory(
|
function createSecuritySeedResourceFactory(
|
||||||
createReplicator: () => LiveSyncCouchDBReplicator | LiveSyncJournalReplicator
|
createReplicator: () => SecuritySeedReplicator
|
||||||
): SecuritySeedResourceFactory {
|
): SecuritySeedResourceFactory {
|
||||||
return (setting) => {
|
return (setting) => {
|
||||||
const snapshot = snapshotRemoteSettings(setting);
|
const snapshot = snapshotRemoteSettings(setting);
|
||||||
|
|||||||
@@ -737,7 +737,8 @@ Success: ${successCount}, Errored: ${errored}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
async compactDatabase() {
|
async compactDatabase() {
|
||||||
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
|
const replicator = this.core.replicator as Partial<LiveSyncCouchDBReplicator>;
|
||||||
|
if (typeof replicator?.connectRemoteCouchDBWithSetting !== "function") return;
|
||||||
const remote = await replicator.connectRemoteCouchDBWithSetting(this.settings, false, false, true);
|
const remote = await replicator.connectRemoteCouchDBWithSetting(this.settings, false, false, true);
|
||||||
if (!remote) {
|
if (!remote) {
|
||||||
this._notice("Failed to connect to remote for compaction.", "gc-compact");
|
this._notice("Failed to connect to remote for compaction.", "gc-compact");
|
||||||
@@ -840,8 +841,14 @@ Success: ${successCount}, Errored: ${errored}`;
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
async gcv3() {
|
async gcv3() {
|
||||||
|
const replicator = this.core.replicator as Partial<LiveSyncCouchDBReplicator>;
|
||||||
|
if (
|
||||||
|
this.settings.remoteType !== REMOTE_COUCHDB ||
|
||||||
|
typeof replicator?.openOneShotReplication !== "function" ||
|
||||||
|
typeof replicator.getConnectedDeviceList !== "function"
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (!(await this.ensureAvailable("Garbage Collection"))) return;
|
if (!(await this.ensureAvailable("Garbage Collection"))) return;
|
||||||
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
|
|
||||||
// Start one-shot replication to ensure all changes are synced before GC.
|
// Start one-shot replication to ensure all changes are synced before GC.
|
||||||
const r0 = await replicator.openOneShotReplication(this.settings, false, false, "sync");
|
const r0 = await replicator.openOneShotReplication(this.settings, false, false, "sync");
|
||||||
if (!r0) {
|
if (!r0) {
|
||||||
@@ -854,7 +861,7 @@ Success: ${successCount}, Errored: ${errored}`;
|
|||||||
// Delete the chunk, but first verify the following:
|
// Delete the chunk, but first verify the following:
|
||||||
// Fetch the list of accepted nodes from the replicator.
|
// Fetch the list of accepted nodes from the replicator.
|
||||||
const OPTION_CANCEL = "Cancel Garbage Collection";
|
const OPTION_CANCEL = "Cancel Garbage Collection";
|
||||||
const info = await this.core.replicator.getConnectedDeviceList();
|
const info = await replicator.getConnectedDeviceList();
|
||||||
if (!info) {
|
if (!info) {
|
||||||
this._notice("No connected device information found. Cancelling Garbage Collection.");
|
this._notice("No connected device information found. Cancelling Garbage Collection.");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type PouchDB from "pouchdb-core";
|
import type PouchDB from "pouchdb-core";
|
||||||
import { fireAndForget } from "octagonal-wheels/promises";
|
import { fireAndForget } from "octagonal-wheels/promises";
|
||||||
import { AbstractModule } from "@/modules/AbstractModule";
|
import { AbstractModule } from "@/modules/AbstractModule";
|
||||||
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
import { Logger, LOG_LEVEL_NOTICE, LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger";
|
||||||
import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
import { skipIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||||
import { balanceChunkPurgedDBs } from "@vrtmrz/livesync-commonlib/compat/pouchdb/chunks";
|
import { balanceChunkPurgedDBs } from "@vrtmrz/livesync-commonlib/compat/pouchdb/chunks";
|
||||||
import { purgeUnreferencedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/chunks";
|
import { purgeUnreferencedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/chunks";
|
||||||
@@ -23,7 +23,13 @@ import { clearHandlers } from "@vrtmrz/livesync-commonlib/compat/replication/Syn
|
|||||||
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
import type { NecessaryServices } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||||
import { MARK_LOG_NETWORK_ERROR } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
import { MARK_LOG_NETWORK_ERROR } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
||||||
import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings.ts";
|
import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings.ts";
|
||||||
import { NO_INTERACTION, type ReplicationInteraction } from "@vrtmrz/livesync-commonlib/replication";
|
import {
|
||||||
|
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
|
||||||
|
NO_INTERACTION,
|
||||||
|
REMOTE_RESOURCE_KINDS,
|
||||||
|
type ReplicationFailureRequest,
|
||||||
|
} from "@vrtmrz/livesync-commonlib/replication";
|
||||||
|
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource.ts";
|
||||||
|
|
||||||
function isOnlineAndCanReplicate(
|
function isOnlineAndCanReplicate(
|
||||||
errorManager: UnresolvedErrorManager,
|
errorManager: UnresolvedErrorManager,
|
||||||
@@ -38,31 +44,36 @@ function isOnlineAndCanReplicate(
|
|||||||
errorManager.clearError(errorMessage);
|
errorManager.clearError(errorMessage);
|
||||||
return Promise.resolve(true);
|
return Promise.resolve(true);
|
||||||
}
|
}
|
||||||
async function canReplicateWithPBKDF2(
|
/** Refresh and validate the selected central provider's owned Security Seed resource. */
|
||||||
|
async function canReplicateWithSecuritySeed(
|
||||||
errorManager: UnresolvedErrorManager,
|
errorManager: UnresolvedErrorManager,
|
||||||
host: NecessaryServices<"replicator" | "setting", never>,
|
host: NecessaryServices<"replicator" | "setting", never>,
|
||||||
showMessage: boolean
|
showMessage: boolean
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const currentSettings = host.services.setting.currentSettings();
|
const currentSettings = host.services.setting.currentSettings();
|
||||||
// TODO: check using PBKDF2 salt?
|
|
||||||
const errorMessage = $msg("Replicator.Message.InitialiseFatalError");
|
const errorMessage = $msg("Replicator.Message.InitialiseFatalError");
|
||||||
const replicator = host.services.replicator.getActiveReplicator();
|
|
||||||
if (!replicator) {
|
|
||||||
errorManager.showError(errorMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
errorManager.clearError(errorMessage);
|
|
||||||
// Showing message is false: that because be shown here. (And it is a fatal error, no way to hide it).
|
// 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
|
// 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.`;
|
const ensureMessage = `${MARK_LOG_NETWORK_ERROR}Failed to initialise the encryption key, preventing replication.`;
|
||||||
// A remote database rebuild replaces the Security Seed while this process may still hold the previous one.
|
try {
|
||||||
const ensureResult = await replicator.ensurePBKDF2Salt(currentSettings, showMessage, false);
|
const resource = await host.services.replicator.createRemoteResource(
|
||||||
if (!ensureResult) {
|
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);
|
errorManager.showError(ensureMessage, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
errorManager.clearError(ensureMessage);
|
errorManager.clearError(ensureMessage);
|
||||||
return ensureResult; // is true.
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ModuleReplicator extends AbstractModule {
|
export class ModuleReplicator extends AbstractModule {
|
||||||
@@ -158,8 +169,14 @@ export class ModuleReplicator extends AbstractModule {
|
|||||||
* database again, or purge unreferenced local chunks before accepting this device again.
|
* 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 showMessage Whether to show the recovery choices as user-facing notices.
|
||||||
|
* @param setting Detached settings used by the failed attempt.
|
||||||
|
* @param expectedContext Publication which produced the compatibility rejection.
|
||||||
*/
|
*/
|
||||||
async cleaned(showMessage: boolean) {
|
async cleaned(
|
||||||
|
showMessage: boolean,
|
||||||
|
setting: ObsidianLiveSyncSettings,
|
||||||
|
expectedContext: ReplicationFailureRequest["context"]
|
||||||
|
) {
|
||||||
Logger(`The remote database has been cleaned.`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
Logger(`The remote database has been cleaned.`, showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO);
|
||||||
await skipIfDuplicated("cleanup", async () => {
|
await skipIfDuplicated("cleanup", async () => {
|
||||||
const count = await purgeUnreferencedChunks(this.localDatabase.localDatabase, true);
|
const count = await purgeUnreferencedChunks(this.localDatabase.localDatabase, true);
|
||||||
@@ -183,82 +200,97 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
|||||||
}
|
}
|
||||||
if (ret == CHOICE_CLEAN) {
|
if (ret == CHOICE_CLEAN) {
|
||||||
await this.services.replicator.runBoundedRemoteActivity(
|
await this.services.replicator.runBoundedRemoteActivity(
|
||||||
async () => {
|
() =>
|
||||||
const replicator = this.services.replicator.getActiveReplicator();
|
this.services.replicator.runWithActiveReplicatorContext(async (context) => {
|
||||||
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
|
if (context !== expectedContext) return;
|
||||||
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(
|
const replicator = context.replicator;
|
||||||
this.settings,
|
if (!(replicator instanceof LiveSyncCouchDBReplicator)) return;
|
||||||
this.services.API.isMobile(),
|
const remoteDB = await replicator.connectRemoteCouchDBWithSetting(
|
||||||
true
|
setting,
|
||||||
);
|
this.services.API.isMobile(),
|
||||||
if (typeof remoteDB == "string") {
|
true
|
||||||
Logger(remoteDB, LOG_LEVEL_NOTICE);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
|
||||||
this.localDatabase.clearCaches();
|
|
||||||
// Perform the synchronisation once.
|
|
||||||
const replicated = await this.services.replicator.runFiniteReplicationActivity(
|
|
||||||
() => this.core.replicator.openReplication(this.settings, false, showMessage, true),
|
|
||||||
{ label: "replication" }
|
|
||||||
);
|
);
|
||||||
if (replicated) {
|
if (typeof remoteDB == "string") {
|
||||||
await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db);
|
Logger(remoteDB, LOG_LEVEL_NOTICE);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||||
this.localDatabase.clearCaches();
|
this.localDatabase.clearCaches();
|
||||||
await this.services.replicator.getActiveReplicator()?.markRemoteResolved(this.settings);
|
// Perform the synchronisation once.
|
||||||
Logger(
|
const replicated = await this.services.replicator.runFiniteReplicationActivity(
|
||||||
"The local database has been cleaned up.",
|
() => replicator.openOneShotReplication(setting, showMessage, false, "sync", true),
|
||||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
{ label: "replication" }
|
||||||
);
|
|
||||||
} else {
|
|
||||||
Logger(
|
|
||||||
"Replication has been cancelled. Please try it again.",
|
|
||||||
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
|
||||||
);
|
);
|
||||||
|
if (replicated) {
|
||||||
|
await balanceChunkPurgedDBs(this.localDatabase.localDatabase, remoteDB.db);
|
||||||
|
await purgeUnreferencedChunks(this.localDatabase.localDatabase, false);
|
||||||
|
this.localDatabase.clearCaches();
|
||||||
|
await replicator.markRemoteResolved(setting);
|
||||||
|
Logger(
|
||||||
|
"The local database has been cleaned up.",
|
||||||
|
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Logger(
|
||||||
|
"Replication has been cancelled. Please try it again.",
|
||||||
|
showMessage ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await remoteDB.close();
|
||||||
}
|
}
|
||||||
} finally {
|
}),
|
||||||
await remoteDB.db.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ label: "database-cleanup" }
|
{ label: "database-cleanup" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onReplicationFailed(
|
private async onReplicationFailed(request: ReplicationFailureRequest): Promise<boolean> {
|
||||||
showMessageOrInteraction: boolean | ReplicationInteraction = false,
|
const { context, interaction, outcome, setting, showMessage } = request;
|
||||||
interaction?: ReplicationInteraction
|
|
||||||
): Promise<boolean> {
|
|
||||||
// The typed ReplicationService passes the legacy visibility flag first
|
|
||||||
// and the authority second. The authority is the source of truth for
|
|
||||||
// recovery dialogues when it is present; retain the legacy boolean for
|
|
||||||
// older callers which do not provide one.
|
|
||||||
const showMessage = interaction
|
|
||||||
? interaction.kind === "permitted" && interaction.permissions.failureRecovery
|
|
||||||
: typeof showMessageOrInteraction === "boolean"
|
|
||||||
? showMessageOrInteraction
|
|
||||||
: showMessageOrInteraction.kind === "permitted" && showMessageOrInteraction.permissions.failureRecovery;
|
|
||||||
const activeReplicator = this.services.replicator.getActiveReplicator();
|
|
||||||
if (!activeReplicator) {
|
|
||||||
Logger(`No active replicator found`, LOG_LEVEL_INFO);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!showMessage) {
|
if (!showMessage) {
|
||||||
// Automatic requests may report the failure, but they must never
|
// Automatic requests may report the failure, but they must never
|
||||||
// enter tweak, lock, fetch, unlock, or cleanup dialogues.
|
// enter tweak, lock, fetch, unlock, or cleanup 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;
|
||||||
}
|
}
|
||||||
if (activeReplicator.tweakSettingsMismatched && activeReplicator.preferredTweakValue) {
|
if (interaction.kind !== "permitted" || !interaction.permissions.failureRecovery) return false;
|
||||||
await this.services.tweakValue.askResolvingMismatched(activeReplicator.preferredTweakValue);
|
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 {
|
} else {
|
||||||
if (activeReplicator.remoteLockedAndDeviceNotAccepted) {
|
if (
|
||||||
if (activeReplicator.remoteCleaned && usesLegacyIndexedDBAdapter(this.settings)) {
|
recovery.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED ||
|
||||||
await this.cleaned(showMessage);
|
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 {
|
} else {
|
||||||
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");
|
||||||
@@ -279,8 +311,19 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
|||||||
this.services.appLifecycle.scheduleRestart();
|
this.services.appLifecycle.scheduleRestart();
|
||||||
return false;
|
return false;
|
||||||
} else if (ret == CHOICE_UNLOCK) {
|
} else if (ret == CHOICE_UNLOCK) {
|
||||||
await activeReplicator.markRemoteResolved(this.settings);
|
let unlocked = false;
|
||||||
this._log($msg("Replicator.Dialogue.Locked.Message.Unlocked"), LOG_LEVEL_NOTICE);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,16 +403,20 @@ Even if you choose to clean up, you will see this option again if you exit Obsid
|
|||||||
},
|
},
|
||||||
serviceModules: {},
|
serviceModules: {},
|
||||||
});
|
});
|
||||||
const canReplicateWithPBKDF2WithHost = canReplicateWithPBKDF2.bind(null, this._unresolvedErrorManager, {
|
const canReplicateWithSecuritySeedWithHost = canReplicateWithSecuritySeed.bind(
|
||||||
services: {
|
null,
|
||||||
context: services.context,
|
this._unresolvedErrorManager,
|
||||||
replicator: services.replicator,
|
{
|
||||||
setting: services.setting,
|
services: {
|
||||||
},
|
context: services.context,
|
||||||
serviceModules: {},
|
replicator: services.replicator,
|
||||||
});
|
setting: services.setting,
|
||||||
|
},
|
||||||
|
serviceModules: {},
|
||||||
|
}
|
||||||
|
);
|
||||||
services.replication.onBeforeReplicate.addHandler(isOnlineAndCanReplicateWithHost, 10);
|
services.replication.onBeforeReplicate.addHandler(isOnlineAndCanReplicateWithHost, 10);
|
||||||
services.replication.onPrepareCentralRemoteReplication.addHandler(canReplicateWithPBKDF2WithHost);
|
services.replication.onPrepareCentralRemoteReplication.addHandler(canReplicateWithSecuritySeedWithHost);
|
||||||
// <-- End of handlers that can be separated.
|
// <-- End of handlers that can be separated.
|
||||||
services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this), 100);
|
services.replication.onBeforeReplicate.addHandler(this._everyBeforeReplicate.bind(this), 100);
|
||||||
services.replication.onReplicationFailed.addHandler(this.onReplicationFailed.bind(this));
|
services.replication.onReplicationFailed.addHandler(this.onReplicationFailed.bind(this));
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||||
import { EVENT_SETTING_SAVED, eventHub } from "@/common/events";
|
import { EVENT_SETTING_SAVED, eventHub } from "@/common/events";
|
||||||
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
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(() => ({
|
const chunkMocks = vi.hoisted(() => ({
|
||||||
purgeUnreferencedChunks: vi.fn(async (_db: unknown, countOnly: boolean) => (countOnly ? 2 : 0)),
|
purgeUnreferencedChunks: vi.fn(async (_db: unknown, countOnly: boolean) => (countOnly ? 2 : 0)),
|
||||||
@@ -18,13 +24,15 @@ import { ModuleReplicator } from "./ModuleReplicator";
|
|||||||
|
|
||||||
describe("ModuleReplicator", () => {
|
describe("ModuleReplicator", () => {
|
||||||
it("refreshes the remote Security Seed before replication", async () => {
|
it("refreshes the remote Security Seed before replication", async () => {
|
||||||
const ensurePBKDF2Salt = vi.fn(async () => true);
|
const read = vi.fn(async () => new Uint8Array([1]));
|
||||||
|
const dispose = vi.fn(async () => undefined);
|
||||||
|
const createRemoteResource = vi.fn(async () => ({ read, dispose }));
|
||||||
let prepareCentralRemoteReplication: ((showMessage: boolean) => Promise<boolean>) | undefined;
|
let prepareCentralRemoteReplication: ((showMessage: boolean) => Promise<boolean>) | undefined;
|
||||||
const services = {
|
const services = {
|
||||||
API: { isOnline: true },
|
API: { isOnline: true },
|
||||||
replicator: {
|
replicator: {
|
||||||
onBeforeReplicatorPublication: { addHandler: vi.fn() },
|
onBeforeReplicatorPublication: { addHandler: vi.fn() },
|
||||||
getActiveReplicator: () => ({ ensurePBKDF2Salt }),
|
createRemoteResource,
|
||||||
},
|
},
|
||||||
setting: { currentSettings: () => ({}) },
|
setting: { currentSettings: () => ({}) },
|
||||||
databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } },
|
databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } },
|
||||||
@@ -58,11 +66,15 @@ describe("ModuleReplicator", () => {
|
|||||||
|
|
||||||
await prepareCentralRemoteReplication!(false);
|
await prepareCentralRemoteReplication!(false);
|
||||||
|
|
||||||
expect(ensurePBKDF2Salt).toHaveBeenCalledWith({}, false, false);
|
expect(createRemoteResource).toHaveBeenCalledWith("security-seed", {});
|
||||||
|
expect(read).toHaveBeenCalledOnce();
|
||||||
|
expect(dispose).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps online and general pre-replication handlers for P2P while skipping central-remote Security Seed preparation", async () => {
|
it("keeps online and general pre-replication handlers for P2P while skipping central-remote Security Seed preparation", async () => {
|
||||||
const ensurePBKDF2Salt = vi.fn(async () => true);
|
const read = vi.fn(async () => new Uint8Array([1]));
|
||||||
|
const dispose = vi.fn(async () => undefined);
|
||||||
|
const createRemoteResource = vi.fn(async () => ({ read, dispose }));
|
||||||
const handlers = new Map<number, (...args: unknown[]) => Promise<boolean | void>>();
|
const handlers = new Map<number, (...args: unknown[]) => Promise<boolean | void>>();
|
||||||
const centralRemoteHandlers: Array<(...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) => {
|
const addHandler = vi.fn((handler: (...args: unknown[]) => Promise<boolean | void>, priority?: number) => {
|
||||||
@@ -72,7 +84,7 @@ describe("ModuleReplicator", () => {
|
|||||||
API: { isOnline: true },
|
API: { isOnline: true },
|
||||||
replicator: {
|
replicator: {
|
||||||
onBeforeReplicatorPublication: { addHandler: vi.fn() },
|
onBeforeReplicatorPublication: { addHandler: vi.fn() },
|
||||||
getActiveReplicator: () => ({ ensurePBKDF2Salt }),
|
createRemoteResource,
|
||||||
},
|
},
|
||||||
setting: { currentSettings: () => ({}) },
|
setting: { currentSettings: () => ({}) },
|
||||||
databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } },
|
databaseEvents: { onDatabaseInitialised: { addHandler: vi.fn() } },
|
||||||
@@ -114,10 +126,12 @@ describe("ModuleReplicator", () => {
|
|||||||
await expect(general!(false)).resolves.toBe(true);
|
await expect(general!(false)).resolves.toBe(true);
|
||||||
|
|
||||||
expect(generalBeforeReplicate).toHaveBeenCalledOnce();
|
expect(generalBeforeReplicate).toHaveBeenCalledOnce();
|
||||||
expect(ensurePBKDF2Salt).not.toHaveBeenCalled();
|
expect(createRemoteResource).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await expect(securitySeed!(false)).resolves.toBe(true);
|
await expect(securitySeed!(false)).resolves.toBe(true);
|
||||||
expect(ensurePBKDF2Salt).toHaveBeenCalledOnce();
|
expect(createRemoteResource).toHaveBeenCalledOnce();
|
||||||
|
expect(read).toHaveBeenCalledOnce();
|
||||||
|
expect(dispose).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reprocesses stored documents when the normal-file target filters change", async () => {
|
it("reprocesses stored documents when the normal-file target filters change", async () => {
|
||||||
@@ -174,12 +188,23 @@ describe("ModuleReplicator", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("only permits recovery dialogue when the authority grants failure recovery", async () => {
|
it("uses the exact failed outcome and permits dialogue only with recovery authority", async () => {
|
||||||
const askResolvingMismatched = vi.fn(async () => undefined);
|
const askResolvingMismatched = vi.fn(async (..._args: unknown[]) => undefined);
|
||||||
const activeReplicator = {
|
const failedSetPreferred = vi.fn(async (_setting: unknown) => undefined);
|
||||||
|
const failedReplicator = { setPreferredRemoteTweakSettings: failedSetPreferred };
|
||||||
|
const replacementSetPreferred = vi.fn(async (_setting: unknown) => undefined);
|
||||||
|
const replacementReplicator = {
|
||||||
tweakSettingsMismatched: true,
|
tweakSettingsMismatched: true,
|
||||||
preferredTweakValue: { customChunkSize: 60 },
|
preferredTweakValue: { customChunkSize: 99 },
|
||||||
|
setPreferredRemoteTweakSettings: replacementSetPreferred,
|
||||||
};
|
};
|
||||||
|
const context = { provider: {}, replicator: failedReplicator };
|
||||||
|
const replacementContext = { provider: {}, replicator: replacementReplicator };
|
||||||
|
const preferredTweakValue = { customChunkSize: 60 };
|
||||||
|
const outcome = replicationFailed(new Error("mismatched"), {
|
||||||
|
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
|
||||||
|
preferredTweakValue,
|
||||||
|
});
|
||||||
const services = {
|
const services = {
|
||||||
context: createServiceContext(),
|
context: createServiceContext(),
|
||||||
API: {
|
API: {
|
||||||
@@ -192,7 +217,12 @@ describe("ModuleReplicator", () => {
|
|||||||
appLifecycle: {
|
appLifecycle: {
|
||||||
getUnresolvedMessages: { addHandler: vi.fn() },
|
getUnresolvedMessages: { addHandler: vi.fn() },
|
||||||
},
|
},
|
||||||
replicator: { getActiveReplicator: vi.fn(() => activeReplicator) },
|
replicator: {
|
||||||
|
getActiveReplicator: vi.fn(() => replacementReplicator),
|
||||||
|
runWithActiveReplicatorContext: vi.fn(async (task: (context: unknown) => unknown) =>
|
||||||
|
task(replacementContext)
|
||||||
|
),
|
||||||
|
},
|
||||||
tweakValue: { askResolvingMismatched },
|
tweakValue: { askResolvingMismatched },
|
||||||
};
|
};
|
||||||
const core = {
|
const core = {
|
||||||
@@ -202,30 +232,137 @@ describe("ModuleReplicator", () => {
|
|||||||
} as any;
|
} as any;
|
||||||
const module = new ModuleReplicator(core);
|
const module = new ModuleReplicator(core);
|
||||||
|
|
||||||
await (module as any).onReplicationFailed(false);
|
await (module as any).onReplicationFailed({
|
||||||
|
context,
|
||||||
|
setting: {},
|
||||||
|
outcome,
|
||||||
|
showMessage: false,
|
||||||
|
interaction: NO_INTERACTION,
|
||||||
|
});
|
||||||
expect(askResolvingMismatched).not.toHaveBeenCalled();
|
expect(askResolvingMismatched).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await (module as any).onReplicationFailed(true, {
|
await (module as any).onReplicationFailed({
|
||||||
kind: "permitted",
|
context,
|
||||||
permissions: {
|
setting: {},
|
||||||
peerSelection: true,
|
outcome,
|
||||||
localPeerAdmission: true,
|
showMessage: false,
|
||||||
configurationExchange: true,
|
interaction: {
|
||||||
failureRecovery: false,
|
kind: "permitted",
|
||||||
|
permissions: { ...USER_INITIATED_REPLICATION_AUTHORITY.permissions, failureRecovery: false },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(askResolvingMismatched).not.toHaveBeenCalled();
|
expect(askResolvingMismatched).not.toHaveBeenCalled();
|
||||||
|
|
||||||
await (module as any).onReplicationFailed(true, {
|
await (module as any).onReplicationFailed({
|
||||||
kind: "permitted",
|
context,
|
||||||
permissions: {
|
setting: {},
|
||||||
peerSelection: true,
|
outcome,
|
||||||
localPeerAdmission: true,
|
showMessage: true,
|
||||||
configurationExchange: true,
|
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
failureRecovery: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
expect(askResolvingMismatched).toHaveBeenCalledOnce();
|
expect(askResolvingMismatched).toHaveBeenCalledWith(preferredTweakValue, expect.any(Function));
|
||||||
|
const updatePreferredRemote = askResolvingMismatched.mock.calls[0][1] as (
|
||||||
|
setting: Record<string, unknown>
|
||||||
|
) => Promise<boolean>;
|
||||||
|
await expect(updatePreferredRemote({ customChunkSize: 64 } as any)).resolves.toBe(false);
|
||||||
|
expect(failedSetPreferred).not.toHaveBeenCalled();
|
||||||
|
expect(replacementSetPreferred).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a mismatch decision only through the still-active failed publication", async () => {
|
||||||
|
const setPreferredRemoteTweakSettings = vi.fn(async (_setting: unknown) => undefined);
|
||||||
|
const context = { provider: {}, replicator: { setPreferredRemoteTweakSettings } };
|
||||||
|
let updatePreferredRemote:
|
||||||
|
| ((setting: Record<string, unknown>) => Promise<boolean>)
|
||||||
|
| undefined;
|
||||||
|
const askResolvingMismatched = vi.fn(
|
||||||
|
async (_preferred: unknown, update: (setting: Record<string, unknown>) => Promise<boolean>) => {
|
||||||
|
updatePreferredRemote = update;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const services = {
|
||||||
|
context: createServiceContext(),
|
||||||
|
API: {
|
||||||
|
addLog: vi.fn(),
|
||||||
|
addCommand: vi.fn(),
|
||||||
|
registerWindow: vi.fn(),
|
||||||
|
addRibbonIcon: vi.fn(),
|
||||||
|
registerProtocolHandler: vi.fn(),
|
||||||
|
},
|
||||||
|
appLifecycle: { getUnresolvedMessages: { addHandler: vi.fn() } },
|
||||||
|
replicator: {
|
||||||
|
runWithActiveReplicatorContext: vi.fn(async (task: (activeContext: unknown) => unknown) =>
|
||||||
|
task(context)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
tweakValue: { askResolvingMismatched },
|
||||||
|
};
|
||||||
|
const module = new ModuleReplicator({ _services: services, services, settings: {} } as any);
|
||||||
|
|
||||||
|
await (module as any).onReplicationFailed({
|
||||||
|
context,
|
||||||
|
setting: {},
|
||||||
|
outcome: replicationFailed(new Error("mismatched"), {
|
||||||
|
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH,
|
||||||
|
preferredTweakValue: { customChunkSize: 60 },
|
||||||
|
}),
|
||||||
|
showMessage: true,
|
||||||
|
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveSetting = { customChunkSize: 64 };
|
||||||
|
await expect(updatePreferredRemote?.(effectiveSetting)).resolves.toBe(true);
|
||||||
|
expect(setPreferredRemoteTweakSettings).toHaveBeenCalledWith(effectiveSetting);
|
||||||
|
expect(setPreferredRemoteTweakSettings.mock.calls[0][0]).not.toBe(effectiveSetting);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply an unlock selected for a replaced failed publication", async () => {
|
||||||
|
const failedMarkResolved = vi.fn(async () => undefined);
|
||||||
|
const replacementMarkResolved = vi.fn(async () => undefined);
|
||||||
|
const failedContext = { provider: {}, replicator: { markRemoteResolved: failedMarkResolved } };
|
||||||
|
const replacementContext = { provider: {}, replicator: { markRemoteResolved: replacementMarkResolved } };
|
||||||
|
const runWithActiveReplicatorContext = vi.fn(async (task: (context: unknown) => unknown) =>
|
||||||
|
task(replacementContext)
|
||||||
|
);
|
||||||
|
const services = {
|
||||||
|
context: createServiceContext(),
|
||||||
|
API: {
|
||||||
|
addLog: vi.fn(),
|
||||||
|
addCommand: vi.fn(),
|
||||||
|
registerWindow: vi.fn(),
|
||||||
|
addRibbonIcon: vi.fn(),
|
||||||
|
registerProtocolHandler: vi.fn(),
|
||||||
|
},
|
||||||
|
appLifecycle: {
|
||||||
|
getUnresolvedMessages: { addHandler: vi.fn() },
|
||||||
|
scheduleRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
replicator: { runWithActiveReplicatorContext },
|
||||||
|
};
|
||||||
|
const core = {
|
||||||
|
_services: services,
|
||||||
|
services,
|
||||||
|
settings: {},
|
||||||
|
confirm: {
|
||||||
|
askSelectStringDialogue: vi.fn(async (_message: string, choices: string[]) => choices[1]),
|
||||||
|
},
|
||||||
|
rebuilder: { scheduleFetch: vi.fn() },
|
||||||
|
} as any;
|
||||||
|
const module = new ModuleReplicator(core);
|
||||||
|
|
||||||
|
await (module as any).onReplicationFailed({
|
||||||
|
context: failedContext,
|
||||||
|
setting: {},
|
||||||
|
outcome: replicationFailed(new Error("locked"), {
|
||||||
|
reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED,
|
||||||
|
}),
|
||||||
|
showMessage: true,
|
||||||
|
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runWithActiveReplicatorContext).toHaveBeenCalledOnce();
|
||||||
|
expect(failedMarkResolved).not.toHaveBeenCalled();
|
||||||
|
expect(replacementMarkResolved).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -240,14 +377,20 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task());
|
||||||
const openReplication = vi.fn(async () => true);
|
const openOneShotReplication = vi.fn(async () => true);
|
||||||
const remoteDatabase = {
|
const remoteDatabase = {
|
||||||
close: vi.fn(async () => undefined),
|
close: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
|
const close = vi.fn(async () => undefined);
|
||||||
const activeReplicator = Object.assign(new LiveSyncCouchDBReplicator({} as any), {
|
const activeReplicator = Object.assign(new LiveSyncCouchDBReplicator({} as any), {
|
||||||
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
|
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase, close })),
|
||||||
|
openOneShotReplication,
|
||||||
markRemoteResolved: vi.fn(async () => undefined),
|
markRemoteResolved: vi.fn(async () => undefined),
|
||||||
});
|
});
|
||||||
|
const expectedContext = { provider: {}, replicator: activeReplicator };
|
||||||
|
const runWithActiveReplicatorContext = vi.fn(async (task: (context: unknown) => unknown) =>
|
||||||
|
task(expectedContext)
|
||||||
|
);
|
||||||
const services = {
|
const services = {
|
||||||
context: createServiceContext(),
|
context: createServiceContext(),
|
||||||
API: {
|
API: {
|
||||||
@@ -266,6 +409,7 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", (
|
|||||||
getActiveReplicator: vi.fn(() => activeReplicator),
|
getActiveReplicator: vi.fn(() => activeReplicator),
|
||||||
runBoundedRemoteActivity,
|
runBoundedRemoteActivity,
|
||||||
runFiniteReplicationActivity,
|
runFiniteReplicationActivity,
|
||||||
|
runWithActiveReplicatorContext,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const localDatabase = {
|
const localDatabase = {
|
||||||
@@ -278,11 +422,10 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", (
|
|||||||
settings: {},
|
settings: {},
|
||||||
localDatabase,
|
localDatabase,
|
||||||
confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") },
|
confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") },
|
||||||
replicator: { openReplication },
|
|
||||||
} as any;
|
} as any;
|
||||||
const module = new ModuleReplicator(core);
|
const module = new ModuleReplicator(core);
|
||||||
|
|
||||||
await module.cleaned(true);
|
await module.cleaned(true, {} as ObsidianLiveSyncSettings, expectedContext as never);
|
||||||
|
|
||||||
expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), {
|
expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||||
label: "database-cleanup",
|
label: "database-cleanup",
|
||||||
@@ -290,12 +433,13 @@ describe("compatibility: cleaned-remote reconciliation for IndexedDB clients", (
|
|||||||
expect(runFiniteReplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
|
expect(runFiniteReplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
|
||||||
label: "replication",
|
label: "replication",
|
||||||
});
|
});
|
||||||
expect(openReplication).toHaveBeenCalledOnce();
|
expect(runWithActiveReplicatorContext).toHaveBeenCalledOnce();
|
||||||
expect(openReplication.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]);
|
expect(openOneShotReplication).toHaveBeenCalledOnce();
|
||||||
expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce();
|
expect(openOneShotReplication.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
expect(remoteDatabase.close).toHaveBeenCalledOnce();
|
|
||||||
expect(remoteDatabase.close.mock.invocationCallOrder[0]).toBeLessThan(
|
|
||||||
activityFinished.mock.invocationCallOrder[0]
|
activityFinished.mock.invocationCallOrder[0]
|
||||||
);
|
);
|
||||||
|
expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce();
|
||||||
|
expect(close).toHaveBeenCalledOnce();
|
||||||
|
expect(close.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -232,19 +232,24 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
|||||||
return CHOICES[retKey];
|
return CHOICES[retKey];
|
||||||
}
|
}
|
||||||
|
|
||||||
async _askResolvingMismatchedTweaks(): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
async _askResolvingMismatchedTweaks(
|
||||||
if (!this.core.replicator.tweakSettingsMismatched) {
|
preferredSource: TweakValues,
|
||||||
return "OK";
|
updatePreferredRemote?: (setting: ObsidianLiveSyncSettings) => Promise<boolean>
|
||||||
}
|
): Promise<"OK" | "CHECKAGAIN" | "IGNORE"> {
|
||||||
const tweaks = this.core.replicator.preferredTweakValue;
|
const [conf, rebuildRequired] =
|
||||||
if (!tweaks) {
|
await this.services.tweakValue.checkAndAskResolvingMismatched(preferredSource);
|
||||||
return "IGNORE";
|
|
||||||
}
|
|
||||||
const [conf, rebuildRequired] = await this.services.tweakValue.checkAndAskResolvingMismatched(tweaks);
|
|
||||||
if (!conf) return "IGNORE";
|
if (!conf) return "IGNORE";
|
||||||
|
|
||||||
|
const updateRemote = async () => {
|
||||||
|
if (updatePreferredRemote) return await updatePreferredRemote(this.settings);
|
||||||
|
const candidate = this.core.replicator;
|
||||||
|
if (typeof candidate.setPreferredRemoteTweakSettings !== "function") return false;
|
||||||
|
await candidate.setPreferredRemoteTweakSettings(this.settings);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
if (conf === true) {
|
if (conf === true) {
|
||||||
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
|
if (!(await updateRemote())) return "IGNORE";
|
||||||
if (rebuildRequired) {
|
if (rebuildRequired) {
|
||||||
await this.core.rebuilder.$rebuildRemote();
|
await this.core.rebuilder.$rebuildRemote();
|
||||||
}
|
}
|
||||||
@@ -261,7 +266,7 @@ export class ModuleResolvingMismatchedTweaks extends AbstractModule {
|
|||||||
// chunk-generation managers now so hash and splitter changes take effect before retrying.
|
// chunk-generation managers now so hash and splitter changes take effect before retrying.
|
||||||
await this.localDatabase.managers.reinitialise();
|
await this.localDatabase.managers.reinitialise();
|
||||||
}
|
}
|
||||||
await this.core.replicator.setPreferredRemoteTweakSettings(this.settings);
|
if (!(await updateRemote())) return "IGNORE";
|
||||||
if (rebuildRequired) {
|
if (rebuildRequired) {
|
||||||
await this.core.rebuilder.$fetchLocal();
|
await this.core.rebuilder.$fetchLocal();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -272,13 +272,18 @@ describe("ModuleResolvingMismatchedTweaks", () => {
|
|||||||
reinitialise.mockImplementation(async () => {
|
reinitialise.mockImplementation(async () => {
|
||||||
calls.push("reinitialise");
|
calls.push("reinitialise");
|
||||||
});
|
});
|
||||||
|
const updatePreferredRemote = vi.fn(async () => {
|
||||||
|
calls.push("set-preferred");
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const result = await module._askResolvingMismatchedTweaks();
|
const result = await module._askResolvingMismatchedTweaks(preferred, updatePreferredRemote);
|
||||||
|
|
||||||
expect(result).toBe("CHECKAGAIN");
|
expect(result).toBe("CHECKAGAIN");
|
||||||
expect(core.settings).toBe(initialSettings);
|
expect(core.settings).toBe(initialSettings);
|
||||||
expect(core.settings.hashAlg).toBe("xxhash32");
|
expect(core.settings.hashAlg).toBe("xxhash32");
|
||||||
expect(calls).toEqual(["save", "reinitialise", "set-preferred"]);
|
expect(calls).toEqual(["save", "reinitialise", "set-preferred"]);
|
||||||
|
expect(core.replicator.setPreferredRemoteTweakSettings).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ type ErrorInfo = {
|
|||||||
|
|
||||||
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
|
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
|
||||||
|
|
||||||
|
interface CompromisedChunkCounter {
|
||||||
|
countCompromisedChunks(): Promise<number | boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasCompromisedChunkCounter(value: object | undefined): value is CompromisedChunkCounter {
|
||||||
|
return (
|
||||||
|
value !== undefined && "countCompromisedChunks" in value && typeof value.countCompromisedChunks === "function"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
||||||
constructor(
|
constructor(
|
||||||
core: LiveSyncCore,
|
core: LiveSyncCore,
|
||||||
@@ -253,7 +263,10 @@ export class ModuleMigration extends AbstractModule<LiveSyncCore> {
|
|||||||
// Check local database for compromised chunks
|
// Check local database for compromised chunks
|
||||||
const localCompromised = await countCompromisedChunks(this.localDatabase.localDatabase);
|
const localCompromised = await countCompromisedChunks(this.localDatabase.localDatabase);
|
||||||
const remote = this.services.replicator.getActiveReplicator();
|
const remote = this.services.replicator.getActiveReplicator();
|
||||||
const remoteCompromised = this.services.API.isOnline ? await remote?.countCompromisedChunks() : 0;
|
const remoteCompromised =
|
||||||
|
this.services.API.isOnline && hasCompromisedChunkCounter(remote)
|
||||||
|
? await remote.countCompromisedChunks()
|
||||||
|
: 0;
|
||||||
if (localCompromised === false) {
|
if (localCompromised === false) {
|
||||||
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
|
Logger(`Failed to count compromised chunks in local database`, LOG_LEVEL_NOTICE);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export function paneMaintenance(
|
|||||||
paneEl: HTMLElement,
|
paneEl: HTMLElement,
|
||||||
{ addPanel }: PageFunctions
|
{ addPanel }: PageFunctions
|
||||||
): void {
|
): void {
|
||||||
const isRemoteLockedAndDeviceNotAccepted = () => this.core?.replicator?.remoteLockedAndDeviceNotAccepted;
|
const isRemoteLockedAndDeviceNotAccepted = () => !!this.core?.replicator?.remoteLockedAndDeviceNotAccepted;
|
||||||
const isRemoteLocked = () => this.core?.replicator?.remoteLocked;
|
const isRemoteLocked = () => !!this.core?.replicator?.remoteLocked;
|
||||||
// if (this.plugin?.replicator?.remoteLockedAndDeviceNotAccepted) {
|
// if (this.plugin?.replicator?.remoteLockedAndDeviceNotAccepted) {
|
||||||
this.createEl(
|
this.createEl(
|
||||||
paneEl,
|
paneEl,
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ describe("ObsidianReplicatorService", () => {
|
|||||||
allowSleepDuringSynchronisationOnDesktop: false,
|
allowSleepDuringSynchronisationOnDesktop: false,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() },
|
appLifecycleService: {
|
||||||
|
onSuspending: handler(),
|
||||||
|
onUnload: handler(),
|
||||||
|
getUnresolvedMessages: handler(),
|
||||||
|
},
|
||||||
databaseEventService: {
|
databaseEventService: {
|
||||||
onResetDatabase: handler(),
|
onResetDatabase: handler(),
|
||||||
onDatabaseInitialisation: handler(),
|
onDatabaseInitialisation: handler(),
|
||||||
@@ -73,7 +77,11 @@ describe("ObsidianReplicatorService", () => {
|
|||||||
allowSleepDuringSynchronisationOnDesktop: true,
|
allowSleepDuringSynchronisationOnDesktop: true,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() },
|
appLifecycleService: {
|
||||||
|
onSuspending: handler(),
|
||||||
|
onUnload: handler(),
|
||||||
|
getUnresolvedMessages: handler(),
|
||||||
|
},
|
||||||
databaseEventService: {
|
databaseEventService: {
|
||||||
onResetDatabase: handler(),
|
onResetDatabase: handler(),
|
||||||
onDatabaseInitialisation: handler(),
|
onDatabaseInitialisation: handler(),
|
||||||
|
|||||||
Reference in new issue
Block a user