Adopt active Replicator ownership contracts

This commit is contained in:
vorotamoroz
2026-08-30 09:45:00 +00:00
parent db70b4c2b6
commit 7300db08d6
18 changed files with 984 additions and 410 deletions
@@ -1,27 +1,29 @@
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
import {
REMOTE_ADMINISTRATION_ACTIONS,
REMOTE_ADMINISTRATION_FAILURE_REASONS,
REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
isRemoteAdministrationVerified,
type RemoteAdministrationAction,
type RemoteAdministrationResult,
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
isCentralRemoteAdministrationVerified,
type CentralRemoteAdministrationAction,
type CentralRemoteAdministrationResult,
} from "@vrtmrz/livesync-commonlib/replication";
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
import { writeStderrLine } from "@/apps/cli/cliOutput";
import type { CLICommand, CLICommandContext, CLIOptions } from "./types";
const REMOTE_ADMINISTRATION_ACTION_BY_COMMAND = Object.freeze({
"mark-resolved": REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
"lock-remote": REMOTE_ADMINISTRATION_ACTIONS.LOCK,
"unlock-remote": REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
} as const satisfies Partial<Record<CLICommand, RemoteAdministrationAction>>);
const CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND = Object.freeze({
"mark-resolved": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
"lock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
"unlock-remote": CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
} 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. */
export function isRemoteAdministrationCommand(command: CLICommand): command is RemoteAdministrationCommand {
return Object.prototype.hasOwnProperty.call(REMOTE_ADMINISTRATION_ACTION_BY_COMMAND, command);
/** Return whether a CLI command belongs to the central-remote administration category. */
export function isCentralRemoteAdministrationCommand(
command: CLICommand
): command is CentralRemoteAdministrationCommand {
return Object.prototype.hasOwnProperty.call(CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND, command);
}
function detailMessage(detail: unknown): string {
@@ -31,8 +33,8 @@ function detailMessage(detail: unknown): string {
function reportMilestoneObservation(
standardIo: StandardIo,
observation: Extract<
RemoteAdministrationResult["observation"],
{ kind: typeof REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE }
CentralRemoteAdministrationResult["observation"],
{ kind: typeof CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE }
>
): void {
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. */
function reportRemoteAdministrationResult(standardIo: StandardIo, result: RemoteAdministrationResult): void {
if (result.observation?.kind === REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE) {
function reportCentralRemoteAdministrationResult(
standardIo: StandardIo,
result: CentralRemoteAdministrationResult
): void {
if (result.observation?.kind === CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE) {
reportMilestoneObservation(standardIo, result.observation);
return;
}
if (isRemoteAdministrationVerified(result)) {
if (isCentralRemoteAdministrationVerified(result)) {
return;
}
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");
return;
case REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED:
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CONNECTION_FAILED:
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;
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");
return;
case REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED:
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED:
standardIo.writeStderr(
`[Verification] Failed to fetch milestone document: ${detailMessage(result.detail)}\n`
);
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");
return;
case REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_IMPLEMENTED:
case REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE:
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_IMPLEMENTED:
case CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.CAPABILITY_NOT_APPLICABLE:
standardIo.writeStderr("[Verification] Remote administration is unavailable for this provider.\n");
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");
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.
* Mutation exceptions deliberately escape this boundary.
*/
export async function runRemoteAdministrationCommand(
export async function runCentralRemoteAdministrationCommand(
options: CLIOptions,
context: CLICommandContext,
command: RemoteAdministrationCommand
command: CentralRemoteAdministrationCommand
): Promise<boolean> {
const id = options.commandArgs[0]?.trim();
if (id) {
@@ -113,8 +118,8 @@ export async function runRemoteAdministrationCommand(
}
writeStderrLine(context.core.services.context.standardIo, `[Command] ${command}${id ? ` ${id}` : ""}`);
const action = REMOTE_ADMINISTRATION_ACTION_BY_COMMAND[command];
const result = await context.core.services.replicator.runRemoteAdministration({ action });
reportRemoteAdministrationResult(context.core.services.context.standardIo, result);
return isRemoteAdministrationVerified(result) || options.compatRemoteAdminExitZero === true;
const action = CENTRAL_REMOTE_ADMINISTRATION_ACTION_BY_COMMAND[command];
const result = await context.core.services.replicator.runCentralRemoteAdministration({ action });
reportCentralRemoteAdministrationResult(context.core.services.context.standardIo, result);
return isCentralRemoteAdministrationVerified(result) || options.compatRemoteAdminExitZero === true;
}
+12 -5
View File
@@ -21,13 +21,17 @@ import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFu
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
import { writeStderrLine, writeStdoutLine } from "@/apps/cli/cliOutput";
import {
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
isReplicationCompleted,
NO_INTERACTION,
REMOTE_RESOURCE_KINDS,
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
import { isRemoteAdministrationCommand, runRemoteAdministrationCommand } from "./remoteAdministration";
import {
isCentralRemoteAdministrationCommand,
runCentralRemoteAdministrationCommand,
} from "./centralRemoteAdministration";
function redactConnectionString(uri: string): string {
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
// failure so that every reason (locked DB, version mismatch, network
// error, etc.) is surfaced with a CLI-specific actionable message.
const replicator = core.services.replicator.getActiveReplicator();
if (replicator?.remoteLockedAndDeviceNotAccepted) {
const recoveryHint = result.status === "failed" ? result.recoveryHint : undefined;
if (
recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED ||
recoveryHint?.reason === CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_CLEANED
) {
writeStderrLine(
standardIo,
`[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;
}
if (isRemoteAdministrationCommand(options.command)) {
return await runRemoteAdministrationCommand(options, context, options.command);
if (isCentralRemoteAdministrationCommand(options.command)) {
return await runCentralRemoteAdministrationCommand(options, context, options.command);
}
if (options.command === "remote-status") {
+82 -32
View File
@@ -12,11 +12,14 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { runCommand } from "./runCommand";
import type { CLIOptions } from "./types";
import {
REMOTE_ADMINISTRATION_ACTIONS,
REMOTE_ADMINISTRATION_FAILURE_REASONS,
REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
REMOTE_ADMINISTRATION_RESULT_STATUSES,
CENTRAL_REMOTE_ADMINISTRATION_ACTIONS,
CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS,
CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS,
CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES,
REMOTE_RESOURCE_KINDS,
CENTRAL_COMPATIBILITY_REJECTION_REASONS,
REPLICATION_COMPLETED,
replicationFailed,
} from "@vrtmrz/livesync-commonlib/replication";
function createStandardIoMock() {
@@ -56,13 +59,14 @@ function createCoreMock() {
markResolved: vi.fn(async () => {}),
markUnlocked: vi.fn(async () => {}),
markLocked: vi.fn(async () => {}),
replicateUserInitiated: vi.fn(async () => REPLICATION_COMPLETED),
},
replicator: {
runRemoteAdministration: vi.fn(async ({ action }) => ({
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
runCentralRemoteAdministration: vi.fn(async ({ action }) => ({
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFIED,
observation: {
kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
locked: action === REMOTE_ADMINISTRATION_ACTIONS.LOCK,
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
locked: action === CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
accepted: true,
nodeId: "test-node-id",
},
@@ -261,6 +265,27 @@ describe("runCommand abnormal cases", () => {
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 () => {
const core = createCoreMock();
core.serviceModules.fileHandler.dbToStorage.mockResolvedValue(false);
@@ -736,11 +761,36 @@ describe("runCommand abnormal cases", () => {
});
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 () => {
const core = createCoreMock();
core.services.replicator.runRemoteAdministration.mockResolvedValueOnce({
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
});
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 () => {
const core = createCoreMock();
core.services.replicator.runRemoteAdministration.mockResolvedValueOnce({
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.NO_ACTIVE_REPLICATOR,
});
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 () => {
const core = createCoreMock();
const failure = new Error("mutation failed");
core.services.replicator.runRemoteAdministration.mockRejectedValueOnce(failure);
core.services.replicator.runCentralRemoteAdministration.mockRejectedValueOnce(failure);
await expect(
runCommand(
@@ -797,16 +847,16 @@ describe("runCommand abnormal cases", () => {
);
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 () => {
const core = createCoreMock();
core.services.replicator.runRemoteAdministration.mockResolvedValueOnce({
status: REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
core.services.replicator.runCentralRemoteAdministration.mockResolvedValueOnce({
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.POSTCONDITION_MISMATCH,
observation: {
kind: REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
kind: CENTRAL_REMOTE_ADMINISTRATION_OBSERVATION_KINDS.MILESTONE,
locked: false,
accepted: true,
nodeId: "test-node-id",
@@ -835,8 +885,8 @@ describe("runCommand abnormal cases", () => {
core,
});
expect(result).toBe(true);
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
});
expect(core.services.control.applySettings).not.toHaveBeenCalled();
expect(core.services.replication.markResolved).not.toHaveBeenCalled();
@@ -857,8 +907,8 @@ describe("runCommand abnormal cases", () => {
core,
});
expect(result).toBe(true);
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
action: REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED,
});
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
expect(settings.activeConfigurationId).toBe("r1");
@@ -872,8 +922,8 @@ describe("runCommand abnormal cases", () => {
core,
});
expect(result).toBe(true);
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
action: REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
});
expect(core.services.control.applySettings).not.toHaveBeenCalled();
});
@@ -893,8 +943,8 @@ describe("runCommand abnormal cases", () => {
core,
});
expect(result).toBe(true);
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
action: REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.UNLOCK,
});
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
expect(settings.activeConfigurationId).toBe("r1");
@@ -908,8 +958,8 @@ describe("runCommand abnormal cases", () => {
core,
});
expect(result).toBe(true);
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
action: REMOTE_ADMINISTRATION_ACTIONS.LOCK,
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
});
expect(core.services.control.applySettings).not.toHaveBeenCalled();
});
@@ -929,8 +979,8 @@ describe("runCommand abnormal cases", () => {
core,
});
expect(result).toBe(true);
expect(core.services.replicator.runRemoteAdministration).toHaveBeenCalledWith({
action: REMOTE_ADMINISTRATION_ACTIONS.LOCK,
expect(core.services.replicator.runCentralRemoteAdministration).toHaveBeenCalledWith({
action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.LOCK,
});
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
expect(settings.activeConfigurationId).toBe("r1");