Complete central provider, resource, scheduling, and recovery contracts

This commit is contained in:
vorotamoroz
2026-08-31 15:06:06 +00:00
parent 24228bf7cf
commit 1d2077d3fc
18 changed files with 464 additions and 34 deletions
+35 -4
View File
@@ -23,14 +23,24 @@ import {
type SupportedCapability,
} from "@vrtmrz/livesync-commonlib/replication";
/**
* Central milestone administration shared by the two central providers.
*
* The provider definition selects a reader before mutation. CouchDB then owns
* a fresh verification connection, while Object Storage borrows the active
* Journal client. Local node identity is established before either mutation.
*/
const JOURNAL_MILESTONE_PATH = "_00000000-milestone.json";
/** A provider read result, including failures which settled without a throw. */
type CentralMilestoneReadResult =
| { readonly milestone: EntryMilestoneInfo | false | undefined }
| { readonly failureReason: CentralRemoteAdministrationFailureReason; readonly detail?: unknown };
/** A settings-bound postcondition reader prepared before remote mutation. */
type PreparedCentralMilestoneReader = () => Promise<CentralMilestoneReadResult>;
/** Select and validate the provider-specific reader without performing I/O. */
type CentralMilestoneReaderPreparer = (
replicator: CentralRemoteAdministrationReplicator,
setting: RemoteDBSettings
@@ -39,7 +49,7 @@ type CentralMilestoneReaderPreparer = (
type CouchDBAdministrationReplicator = CentralRemoteAdministrationReplicator &
Pick<LiveSyncCouchDBReplicator, "connectRemoteCouchDBWithSetting" | "isMobile">;
type JournalAdministrationClient = Pick<LiveSyncJournalReplicator["client"], "downloadJson">;
type JournalAdministrationClient = Pick<LiveSyncJournalReplicator["client"], "downloadJsonWithResult">;
function isCentralRemoteAdministrationReplicator(
replicator: ReplicatorInstance
@@ -147,6 +157,8 @@ function prepareCouchDBMilestoneReader(
requireCouchDBAdministrationOperations(replicator);
return async () => {
// This verification connection is fresh and owned by this read. It is
// always closed here rather than retained by the active Replicator.
let connection: Awaited<ReturnType<CouchDBAdministrationReplicator["connectRemoteCouchDBWithSetting"]>>;
try {
connection = await replicator.connectRemoteCouchDBWithSetting(setting, replicator.isMobile(), true);
@@ -186,8 +198,8 @@ function isJournalAdministrationClient(client: unknown): client is JournalAdmini
return (
typeof client === "object" &&
client !== null &&
"downloadJson" in client &&
typeof client.downloadJson === "function"
"downloadJsonWithResult" in client &&
typeof client.downloadJsonWithResult === "function"
);
}
@@ -200,14 +212,33 @@ function requireJournalAdministrationClient(
return replicator.client;
}
function assertNeverJournalStorageRead(result: never): never {
throw new Error(`Unexpected Journal storage read result: ${String(result)}`);
}
function prepareObjectStorageMilestoneReader(
replicator: CentralRemoteAdministrationReplicator
): PreparedCentralMilestoneReader {
// The Journal client belongs to the active Replicator. This reader borrows
// it for the provider's distinct milestone path and must not dispose it.
const client = requireJournalAdministrationClient(replicator);
return async () => {
try {
return { milestone: await client.downloadJson<EntryMilestoneInfo>(JOURNAL_MILESTONE_PATH) };
const result = await client.downloadJsonWithResult<EntryMilestoneInfo>(JOURNAL_MILESTONE_PATH);
switch (result.status) {
case "available":
return { milestone: result.value };
case "not-found":
return { milestone: undefined };
case "unavailable":
return {
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
detail: result.error,
};
default:
return assertNeverJournalStorageRead(result);
}
} catch (error) {
return {
failureReason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
@@ -130,13 +130,18 @@ describe("central remote administration capabilities", () => {
});
it("mutates Object Storage and verifies its milestone postcondition", async () => {
const downloadJson = vi.fn(async () => ({ locked: false, accepted_nodes: ["node-1"] }));
const milestone = { locked: false, accepted_nodes: ["node-1"] };
const downloadJson = vi.fn(async () => milestone);
const downloadJsonWithResult = vi.fn(async () => ({
status: "available" as const,
value: milestone,
}));
const replicator = {
nodeid: "node-1",
initializeDatabaseForReplication: vi.fn(async () => true),
markRemoteLocked: vi.fn(async () => undefined),
markRemoteResolved: vi.fn(async () => undefined),
client: { downloadJson },
client: { downloadJson, downloadJsonWithResult },
};
const setting = { ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO };
const capability = OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY;
@@ -155,7 +160,63 @@ describe("central remote administration capabilities", () => {
},
});
expect(replicator.markRemoteResolved).toHaveBeenCalledWith(setting);
expect(downloadJson).toHaveBeenCalledWith("_00000000-milestone.json");
expect(downloadJsonWithResult).toHaveBeenCalledWith("_00000000-milestone.json");
expect(downloadJson).not.toHaveBeenCalled();
});
it("keeps a missing Object Storage milestone as an unverified postcondition", async () => {
const downloadJson = vi.fn(async () => false);
const downloadJsonWithResult = vi.fn(async () => ({ status: "not-found" as const }));
const replicator = {
nodeid: "node-1",
initializeDatabaseForReplication: vi.fn(async () => true),
markRemoteLocked: vi.fn(async () => undefined),
markRemoteResolved: vi.fn(async () => undefined),
client: { downloadJson, downloadJsonWithResult },
};
const result = await OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
replicator as never,
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO },
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
);
expect(result).toEqual({
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_NOT_FOUND,
});
expect(downloadJsonWithResult).toHaveBeenCalledWith("_00000000-milestone.json");
expect(downloadJson).not.toHaveBeenCalled();
});
it("returns a typed failure with diagnostic detail when Object Storage milestone reading is unavailable", async () => {
const diagnostic = new Error("object storage unavailable");
const downloadJson = vi.fn(async () => false);
const downloadJsonWithResult = vi.fn(async () => ({
status: "unavailable" as const,
error: diagnostic,
}));
const replicator = {
nodeid: "node-1",
initializeDatabaseForReplication: vi.fn(async () => true),
markRemoteLocked: vi.fn(async () => undefined),
markRemoteResolved: vi.fn(async () => undefined),
client: { downloadJson, downloadJsonWithResult },
};
const result = await OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY.run(
replicator as never,
{ ...DEFAULT_SETTINGS, remoteType: REMOTE_MINIO },
{ action: CENTRAL_REMOTE_ADMINISTRATION_ACTIONS.MARK_RESOLVED }
);
expect(result).toEqual({
status: CENTRAL_REMOTE_ADMINISTRATION_RESULT_STATUSES.VERIFICATION_FAILED,
reason: CENTRAL_REMOTE_ADMINISTRATION_FAILURE_REASONS.MILESTONE_READ_FAILED,
detail: diagnostic,
});
expect(downloadJsonWithResult).toHaveBeenCalledWith("_00000000-milestone.json");
expect(downloadJson).not.toHaveBeenCalled();
});
it("rejects an incomplete CouchDB milestone adapter before mutation", async () => {
@@ -177,14 +238,14 @@ describe("central remote administration capabilities", () => {
expect(markRemoteLocked).not.toHaveBeenCalled();
});
it("rejects an incomplete Object Storage milestone adapter before mutation", async () => {
it("rejects an Object Storage adapter which only exposes lossy milestone reading", async () => {
const markRemoteResolved = vi.fn(async () => undefined);
const replicator = {
nodeid: "node-1",
initializeDatabaseForReplication: vi.fn(async () => true),
markRemoteLocked: vi.fn(async () => undefined),
markRemoteResolved,
client: {},
client: { downloadJson: vi.fn(async () => false) },
};
await expect(
@@ -2,6 +2,12 @@ import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/
type EndpointProjection = readonly [kind: "url" | "invalid-url", value: string];
/**
* Compare the effective endpoint rather than inconsequential URI spelling.
* Fragments are not sent, query order is immaterial, and redundant trailing
* slashes do not bind a different adapter. Invalid input is retained verbatim
* and tagged so comparison remains deterministic and fails closed.
*/
function projectEndpoint(value: string): EndpointProjection {
try {
const endpoint = new URL(value);
@@ -16,6 +22,11 @@ function projectEndpoint(value: string): EndpointProjection {
}
}
/**
* Mirror the effective custom-header parser: trim each first name/value pair,
* ignore incomplete lines, and let the last duplicate name win. Sorting the
* resulting entries prevents line order alone from replacing a Replicator.
*/
function projectHeaders(value: string): readonly (readonly [name: string, value: string])[] {
const headers = new Map<string, string>();
for (const line of value.split("\n")) {
+7 -1
View File
@@ -47,6 +47,7 @@ interface OneShotOutcomeReplicator extends ReplicatorInstance {
openOneShotReplicationWithOutcome(setting: RemoteDBSettings, showResult: boolean): Promise<ReplicationOutcome>;
}
/** Narrow structurally so the shared adapter does not depend on either concrete provider class. */
function isOneShotOutcomeReplicator(instance: ReplicatorInstance): instance is OneShotOutcomeReplicator {
return (
"openOneShotReplicationWithOutcome" in instance &&
@@ -65,6 +66,8 @@ async function runOneShotWithOutcome(
return await instance.openOneShotReplicationWithOutcome(setting, showResult);
}
// Manual and unattended wrappers share the provider transfer operation, but
// keep interaction authority and result presentation explicit at this boundary.
const couchDBUserInitiatedOneShot: UserInitiatedOneShotRunner = async (instance, setting, request) => {
return await runOneShotWithOutcome(
instance,
@@ -91,7 +94,10 @@ const objectStorageUnattendedOneShot: UnattendedOneShotRunner = async (instance,
return await runOneShotWithOutcome(instance, setting, false);
};
/** Build the complete central-remote provider policy for one LiveSync host. */
/**
* Build the complete, deliberately concrete central-provider matrix for one
* LiveSync host. This closed composition is not a runtime provider registry.
*/
export function createCentralReplicatorProviderDefinitions(
host: CentralReplicatorProviderHost
): ReplicatorProviderDefinitionMap {
+63 -1
View File
@@ -1,9 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LOG_LEVEL_NOTICE, REMOTE_COUCHDB, REMOTE_MINIO } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
const mocks = vi.hoisted(() => ({
logger: vi.fn(),
couchDB: [] as Array<{
host: unknown;
isMobile: ReturnType<typeof vi.fn>;
@@ -24,6 +25,11 @@ const mocks = vi.hoisted(() => ({
checkSyncInfo: vi.fn(async () => true),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/logger")>();
return { ...actual, Logger: mocks.logger };
});
vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation", () => ({
checkSyncInfo: mocks.checkSyncInfo,
}));
@@ -87,6 +93,7 @@ describe("replicator probe factories", () => {
mocks.couchDB.length = 0;
mocks.objectStorage.length = 0;
mocks.checkSyncInfo.mockReset().mockResolvedValue(true);
mocks.logger.mockClear();
});
it("binds a CouchDB connection probe to a shallow settings snapshot and closes its owned connection", async () => {
@@ -129,6 +136,52 @@ describe("replicator probe factories", () => {
expect(replicator.getRemoteStatus).toHaveBeenCalledWith(snapshot);
});
it("emits a result Notice only for an explicitly visible successful CouchDB probe", async () => {
const probe = await createCouchDBConnectionProbeFactory({} as never)(createSettings());
const replicator = mocks.couchDB[0];
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue({
info: { db_name: "vault" },
close: vi.fn(async () => undefined),
});
await expect(probe.check({ showResult: true })).resolves.toEqual({ ok: true });
expect(mocks.logger).toHaveBeenCalledTimes(1);
expect(mocks.logger).toHaveBeenCalledWith("Connected to vault successfully", LOG_LEVEL_NOTICE);
mocks.logger.mockClear();
await expect(probe.check()).resolves.toEqual({ ok: true });
expect(mocks.logger).not.toHaveBeenCalled();
});
it("emits a result Notice only for an explicitly visible CouchDB connection failure", async () => {
const reason = "connection failed";
const translatedFailure = "translated CouchDB connection failure";
const translate = vi.fn(() => translatedFailure);
const settings = createSettings();
const probe = await createCouchDBConnectionProbeFactory({ services: { context: { translate } } } as never)(
settings
);
const replicator = mocks.couchDB[0];
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue(reason);
await expect(probe.check({ showResult: true })).resolves.toEqual({ ok: false, reason });
expect(mocks.logger).toHaveBeenCalledTimes(1);
expect(translate).toHaveBeenCalledWith("liveSyncReplicator.couldNotConnectTo", {
uri: settings.couchDB_URI,
name: settings.couchDB_DBNAME,
db: reason,
});
expect(mocks.logger).toHaveBeenCalledWith(translatedFailure, LOG_LEVEL_NOTICE);
mocks.logger.mockClear();
translate.mockClear();
await expect(probe.check()).resolves.toEqual({ ok: false, reason });
expect(mocks.logger).not.toHaveBeenCalled();
expect(translate).not.toHaveBeenCalled();
});
it("creates an unpublished Object Storage replicator for each probe and normalises connection results", async () => {
const host = { name: "host" };
const source = createSettings({ remoteType: REMOTE_MINIO });
@@ -252,6 +305,15 @@ describe("replicator probe factories", () => {
expect(replicator.closeReplication).toHaveBeenCalledOnce();
});
it("preserves a CouchDB connection or setup failure for the settings flow to report", async () => {
const reason = "connection failed";
const resource = await createCouchDBSynchronisationInformationResourceFactory({} as never)(createSettings());
const replicator = mocks.couchDB[0];
replicator.connectRemoteCouchDBWithSetting.mockResolvedValue(reason);
await expect(resource.check()).rejects.toMatchObject({ message: reason });
});
it("closes the owned connection when synchronisation-information verification rejects", async () => {
const resource = await createCouchDBSynchronisationInformationResourceFactory({} as never)(createSettings());
const replicator = mocks.couchDB[0];
+20 -3
View File
@@ -8,6 +8,7 @@ import {
LiveSyncCouchDBReplicator,
type LiveSyncCouchDBReplicatorEnv,
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { LOG_LEVEL_NOTICE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared";
@@ -16,7 +17,8 @@ export type ConnectionResourceHost = LiveSyncCouchDBReplicatorEnv;
function createCouchDBConnectionProbe(
replicator: LiveSyncCouchDBReplicator,
snapshot: RemoteDBSettings
snapshot: RemoteDBSettings,
host: ConnectionResourceHost
): RemoteConnectionProbe {
const dispose = createReplicatorDisposer(replicator);
return {
@@ -28,9 +30,22 @@ function createCouchDBConnectionProbe(
false
);
if (typeof connection === "string") {
if (options.showResult) {
Logger(
host.services.context.translate("liveSyncReplicator.couldNotConnectTo", {
uri: snapshot.couchDB_URI,
name: snapshot.couchDB_DBNAME,
db: connection,
}),
LOG_LEVEL_NOTICE
);
}
return { ok: false, reason: connection };
}
try {
if (options.showResult) {
Logger(`Connected to ${connection.info.db_name} successfully`, LOG_LEVEL_NOTICE);
}
return { ok: true };
} finally {
await connection.close();
@@ -64,12 +79,14 @@ function createObjectStorageConnectionProbe(
* Build an unpublished CouchDB connection probe for one host.
*
* The probe owns both its concrete Replicator and each connection it opens. It
* never publishes that Replicator as the active provider instance.
* never publishes that Replicator as the active provider instance. A caller
* may request the established result Notice explicitly; ordinary probes remain
* silent.
*/
export function createCouchDBConnectionProbeFactory(host: ConnectionResourceHost): ConnectionProbeFactory {
return (setting) => {
const snapshot = snapshotRemoteSettings(setting);
return Promise.resolve(createCouchDBConnectionProbe(new LiveSyncCouchDBReplicator(host), snapshot));
return Promise.resolve(createCouchDBConnectionProbe(new LiveSyncCouchDBReplicator(host), snapshot, host));
};
}
@@ -10,7 +10,9 @@ import { createReplicatorDisposer, snapshotRemoteSettings } from "./shared";
* Build an unpublished CouchDB synchronisation-information verifier.
*
* The resource owns its concrete Replicator and connection, and cannot replace
* the active provider instance.
* the active provider instance. Its check resolves to `false` only for observed
* incompatibility; connection, setup, and verification failures reject so the
* caller can report an operational failure separately.
*/
export function createCouchDBSynchronisationInformationResourceFactory(
host: LiveSyncCouchDBReplicatorEnv
@@ -26,7 +28,7 @@ export function createCouchDBSynchronisationInformationResourceFactory(
true
);
if (typeof connection === "string") {
return false;
throw new Error(connection);
}
try {
return await checkSyncInfo(connection.db);