Merge main and verify all Automatic initial actions

This commit is contained in:
vorotamoroz committed 2026-10-26 01:49:19 +00:00
commit 40696aa8bc
112 files changed
+6548 -741

No files matched your search

@@ -10,6 +10,71 @@ export const liveSyncProvisionalEnglishMessages = {
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.":
"Hidden File Sync is disabled on this device. Enable it in Sync Settings before using Automatic or initialising hidden files.",
"Hidden File Sync initialisation was not started.": "Hidden File Sync initialisation was not started.",
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.":
"(RegExp) Limit normal files to matching paths. Leave empty to allow all normal file paths. Hidden File Sync uses its own target patterns.",
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.":
"(RegExp) Skip changes to matching normal and hidden files, locally and remotely.",
"Unknown platform": "Unknown platform",
"This removes only this device's record from the remote.":
"This removes only this device's record from the remote.",
"This remote is unlocked. A device that returns can register again.":
"This remote is unlocked. A device that returns can register again.",
"This remote is locked. Use the existing recovery process for devices that need to join again.":
"This remote is locked. Use the existing recovery process for devices that need to join again.",
"This remote is locked. If the device returns, use the existing recovery process to join it again.":
"This remote is locked. If the device returns, use the existing recovery process to join it again.",
"This remote does not support conditional updates, so the device record could not be removed.":
"This remote does not support conditional updates, so the device record could not be removed.",
"This remote does not currently have a record of joined devices.":
"This remote does not currently have a record of joined devices.",
"This list shows device information recorded by the remote. It does not show whether a device is online now.":
"This list shows device information recorded by the remote. It does not show whether a device is online now.",
"This device has been locked out. Use the existing recovery process to join again.":
"This device has been locked out. Use the existing recovery process to join again.",
"This device": "This device",
"This remote has been cleaned. Use the existing recovery process to join devices again as needed.":
"This remote has been cleaned. Use the existing recovery process to join devices again as needed.",
"The device record was forgotten.": "The device record was forgotten.",
"The device record was already absent.": "The device record was already absent.",
"The device list changed while you were trying to forget this device. Refresh the list before trying again.":
"The device list changed while you were trying to forget this device. Refresh the list before trying again.",
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed.":
"The device information could not be loaded. Close this dialogue and open Joined devices again if the remote configuration has changed.",
"Review device records for the active CouchDB or Object Storage connection.":
"Review device records for the active CouchDB or Object Storage connection.",
Refresh: "Refresh",
"Plug-in version": "Plug-in version",
Platform: "Platform",
"Older Self-hosted LiveSync versions detected: ${versions}.":
"Older Self-hosted LiveSync versions detected: ${versions}.",
"Newer Self-hosted LiveSync versions detected: ${versions}.":
"Newer Self-hosted LiveSync versions detected: ${versions}.",
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.":
"Reviewing and aligning Self-hosted LiveSync versions across devices is recommended.",
Open: "Open",
"Not recorded": "Not recorded",
"Not accepted": "Not accepted",
"No joined devices are recorded on this remote.": "No joined devices are recorded on this remote.",
"Locked out": "Locked out",
"Loading joined devices…": "Loading joined devices…",
"Last recorded": "Last recorded",
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again.":
"Joined devices could not be updated. Refresh the list and try again. If the remote configuration has changed, close this dialogue and open Joined devices again.",
"Joined devices are available after database startup with an active CouchDB or Object Storage connection.":
"Joined devices are available after database startup with an active CouchDB or Object Storage connection.",
"Joined devices": "Joined devices",
"Review devices": "Review devices",
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.":
"Synchronisation between different plug-in versions is generally safe unless the connection is explicitly blocked. However, it may be less efficient or use more storage, and P2P synchronisation may be unavailable.",
"Forgetting…": "Forgetting…",
"Forgetting the record does not delete Vault files or revoke credentials.":
"Forgetting the record does not delete Vault files or revoke credentials.",
"Forget this device record?": "Forget this device record?",
"Forget device": "Forget device",
Close: "Close",
Cancel: "Cancel",
"App version": "App version",
Accepted: "Accepted",
"Preparing follow-up report…": "Preparing follow-up report…",
"Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.",
"Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.":
+13
View File
@@ -28,7 +28,10 @@ import {
getObjectStorageReplicatorConfigurationIdentity,
} from "./replicatorConfigurationIdentity";
import {
createCouchDBJoinedDevicesResourceFactory,
createObjectStorageJoinedDevicesResourceFactory,
createCouchDBConnectionProbeFactory,
createCouchDBInitialFetchFastForwardResourceFactory,
createCouchDBPreferredTweakProbeFactory,
createCouchDBSecuritySeedResourceFactory,
createCouchDBSynchronisationInformationResourceFactory,
@@ -115,6 +118,9 @@ export function createCentralReplicatorProviderDefinitions(
configurationIdentity: getCouchDBReplicatorConfigurationIdentity,
create: () => Promise.resolve(new LiveSyncCouchDBReplicator(host)),
remoteResources: {
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
createCouchDBJoinedDevicesResourceFactory(host)
),
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(createCouchDBConnectionProbeFactory(host)),
[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK]: supportedCapability(
createCouchDBPreferredTweakProbeFactory(host)
@@ -125,6 +131,9 @@ export function createCentralReplicatorProviderDefinitions(
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: supportedCapability(
createCouchDBSynchronisationInformationResourceFactory(host)
),
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: supportedCapability(
createCouchDBInitialFetchFastForwardResourceFactory(host)
),
},
centralRemoteAdministration: COUCHDB_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
userInitiatedOneShot: supportedCapability(couchDBUserInitiatedOneShot),
@@ -141,6 +150,9 @@ export function createCentralReplicatorProviderDefinitions(
configurationIdentity: getObjectStorageReplicatorConfigurationIdentity,
create: () => Promise.resolve(new LiveSyncJournalReplicator(host)),
remoteResources: {
[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]: supportedCapability(
createObjectStorageJoinedDevicesResourceFactory(host)
),
[REMOTE_RESOURCE_KINDS.CONNECTION]: supportedCapability(
createObjectStorageConnectionProbeFactory(host)
),
@@ -151,6 +163,7 @@ export function createCentralReplicatorProviderDefinitions(
createObjectStorageSecuritySeedResourceFactory(host)
),
[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION]: CAPABILITY_NOT_APPLICABLE,
[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]: CAPABILITY_NOT_APPLICABLE,
},
centralRemoteAdministration: OBJECT_STORAGE_CENTRAL_REMOTE_ADMINISTRATION_CAPABILITY,
userInitiatedOneShot: supportedCapability(objectStorageUserInitiatedOneShot),
+21 -3
View File
@@ -44,7 +44,16 @@ 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());
.toEqual(
[
"connection",
"initial-fetch-fast-forward",
"joined-devices",
"preferred-tweak",
"security-seed",
"synchronisation-information",
].sort()
);
});
it("lets Journal prepare its own fresh Security Seed while CouchDB uses central preparation", () => {
@@ -106,18 +115,27 @@ describe("central Replicator provider definitions", () => {
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(objectResources ?? {}).sort()).toEqual(Object.values(REMOTE_RESOURCE_KINDS).sort());
const resources = Object.values(REMOTE_RESOURCE_KINDS).sort();
expect(Object.keys(couchResources ?? {}).sort()).toEqual(resources);
expect(Object.keys(objectResources ?? {}).sort()).toEqual(resources);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(objectResources?.[REMOTE_RESOURCE_KINDS.JOINED_DEVICES]?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.CONNECTION].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.PREFERRED_TWEAK].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
CAPABILITY_SUPPORT_KINDS.SUPPORTED
);
expect(couchResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]?.kind).toBe(
CAPABILITY_SUPPORT_KINDS.SUPPORTED
);
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SECURITY_SEED].kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(objectResources?.[REMOTE_RESOURCE_KINDS.SYNCHRONISATION_INFORMATION].kind).toBe(
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
);
expect(objectResources?.[REMOTE_RESOURCE_KINDS.INITIAL_FETCH_FAST_FORWARD]?.kind).toBe(
CAPABILITY_SUPPORT_KINDS.NOT_APPLICABLE
);
expect(couchAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect(objectAdministration?.kind).toBe(CAPABILITY_SUPPORT_KINDS.SUPPORTED);
expect("activeRemoteReads" in definitions.get(REMOTE_COUCHDB)!).toBe(false);
@@ -5,6 +5,7 @@ import { createNewVaultSettings } from "@vrtmrz/livesync-commonlib/settings";
const mocks = vi.hoisted(() => ({
logger: vi.fn(),
initialFetchFastForwardResource: undefined as unknown,
couchDB: [] as Array<{
host: unknown;
isMobile: ReturnType<typeof vi.fn>;
@@ -12,6 +13,9 @@ const mocks = vi.hoisted(() => ({
getRemoteStatus: ReturnType<typeof vi.fn>;
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
getJoinedDevices: ReturnType<typeof vi.fn>;
forgetJoinedDevice: ReturnType<typeof vi.fn>;
createInitialFetchFastForwardResource: ReturnType<typeof vi.fn>;
closeReplication: ReturnType<typeof vi.fn>;
}>,
objectStorage: [] as Array<{
@@ -20,6 +24,8 @@ const mocks = vi.hoisted(() => ({
getRemoteStatus: ReturnType<typeof vi.fn>;
getRemotePreferredTweakValues: ReturnType<typeof vi.fn>;
getReplicationPBKDF2Salt: ReturnType<typeof vi.fn>;
getJoinedDevices: ReturnType<typeof vi.fn>;
forgetJoinedDevice: ReturnType<typeof vi.fn>;
closeReplication: ReturnType<typeof vi.fn>;
}>,
checkSyncInfo: vi.fn(async () => true),
@@ -42,6 +48,9 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicato
getRemoteStatus = vi.fn();
getRemotePreferredTweakValues = vi.fn();
getReplicationPBKDF2Salt = vi.fn();
getJoinedDevices = vi.fn();
forgetJoinedDevice = vi.fn();
createInitialFetchFastForwardResource = vi.fn(() => mocks.initialFetchFastForwardResource);
closeReplication = vi.fn();
constructor(host: unknown) {
@@ -58,6 +67,8 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
getRemoteStatus = vi.fn();
getRemotePreferredTweakValues = vi.fn();
getReplicationPBKDF2Salt = vi.fn();
getJoinedDevices = vi.fn();
forgetJoinedDevice = vi.fn();
closeReplication = vi.fn();
constructor(host: unknown) {
@@ -69,10 +80,13 @@ vi.mock("@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalRe
import {
createCouchDBConnectionProbeFactory,
createCouchDBJoinedDevicesResourceFactory,
createCouchDBInitialFetchFastForwardResourceFactory,
createCouchDBPreferredTweakProbeFactory,
createCouchDBSecuritySeedResourceFactory,
createCouchDBSynchronisationInformationResourceFactory,
createObjectStorageConnectionProbeFactory,
createObjectStorageJoinedDevicesResourceFactory,
createObjectStoragePreferredTweakProbeFactory,
createObjectStorageSecuritySeedResourceFactory,
} from "./replicatorResources";
@@ -92,6 +106,7 @@ describe("replicator probe factories", () => {
beforeEach(() => {
mocks.couchDB.length = 0;
mocks.objectStorage.length = 0;
mocks.initialFetchFastForwardResource = undefined;
mocks.checkSyncInfo.mockReset().mockResolvedValue(true);
mocks.logger.mockClear();
});
@@ -226,6 +241,65 @@ describe("replicator probe factories", () => {
expect(preferredReplicator.getRemotePreferredTweakValues).toHaveBeenCalledWith(snapshot);
});
it.each([
{
kind: "CouchDB",
remoteType: REMOTE_COUCHDB,
createFactory: createCouchDBJoinedDevicesResourceFactory,
instances: () => mocks.couchDB,
},
{
kind: "Object Storage",
remoteType: REMOTE_MINIO,
createFactory: createObjectStorageJoinedDevicesResourceFactory,
instances: () => mocks.objectStorage,
},
])(
"keeps $kind joined-device operations on an owned settings snapshot",
async ({ remoteType, createFactory, instances }) => {
const host = { name: "host" };
const source = createSettings({ remoteType });
const snapshot = { ...source };
const factory = createFactory(host as never);
const resource = await factory(source);
const nextResource = await factory(source);
const [replicator, nextReplicator] = instances();
expect(instances()).toHaveLength(2);
expect(replicator.host).toBe(host);
expect(replicator.getJoinedDevices).not.toHaveBeenCalled();
expect(replicator.forgetJoinedDevice).not.toHaveBeenCalled();
const devices = {
milestonePresent: true,
locked: false,
cleaned: false,
devices: [{ id: "other", accepted: true }],
};
replicator.getJoinedDevices.mockResolvedValue(devices);
replicator.forgetJoinedDevice.mockResolvedValue("conflict");
source.couchDB_DBNAME = "changed-vault";
source.bucket = "changed-bucket";
await expect(resource.read()).resolves.toBe(devices);
await expect(resource.forget("other")).resolves.toBe("conflict");
expect(replicator.getJoinedDevices).toHaveBeenCalledWith(snapshot);
expect(replicator.getJoinedDevices.mock.calls[0][0]).not.toBe(source);
expect(replicator.forgetJoinedDevice).toHaveBeenCalledWith(snapshot, "other");
const failure = new Error("remote unavailable");
replicator.getJoinedDevices.mockRejectedValue(failure);
await expect(resource.read()).rejects.toBe(failure);
replicator.forgetJoinedDevice.mockRejectedValue(failure);
await expect(resource.forget("other")).rejects.toBe(failure);
const disposal = resource.dispose();
expect(resource.dispose()).toBe(disposal);
await disposal;
expect(replicator.closeReplication).toHaveBeenCalledOnce();
expect(nextReplicator.closeReplication).not.toHaveBeenCalled();
await nextResource.dispose();
expect(nextReplicator.closeReplication).toHaveBeenCalledOnce();
}
);
it("shares one successful asynchronous disposal promise for every probe kind", async () => {
const couchProbe = await createCouchDBPreferredTweakProbeFactory({} as never)(createSettings());
const objectProbe = await createObjectStoragePreferredTweakProbeFactory({} as never)(
@@ -370,4 +444,29 @@ describe("replicator probe factories", () => {
await resource.dispose();
expect(replicator.closeReplication).toHaveBeenCalledOnce();
});
it("creates an Initial Fetch Fast Forward resource from a settings snapshot and leaves ownership to the resource", async () => {
const host = { name: "host" };
const settings = createSettings();
const snapshot = { ...settings };
const prepare = vi.fn(async () => undefined);
const dispose = vi.fn(async () => undefined);
const resource = { prepare, dispose };
const factory = createCouchDBInitialFetchFastForwardResourceFactory(host as never);
mocks.initialFetchFastForwardResource = resource;
const result = await factory(settings);
const replicator = mocks.couchDB[0];
settings.couchDB_URI = "https://changed.example.test";
expect(replicator.createInitialFetchFastForwardResource).toHaveBeenCalledWith(snapshot);
expect(replicator.createInitialFetchFastForwardResource.mock.calls[0][0]).not.toBe(settings);
expect(replicator.host).toBe(host);
expect(result).toBe(resource);
await expect(result.prepare()).resolves.toBeUndefined();
await result.dispose();
expect(prepare).toHaveBeenCalledOnce();
expect(dispose).toHaveBeenCalledOnce();
});
});
+6
View File
@@ -14,3 +14,9 @@ export {
type SecuritySeedResourceHost,
} from "./securitySeed";
export { createCouchDBSynchronisationInformationResourceFactory } from "./synchronisationInformation";
export {
createCouchDBJoinedDevicesResourceFactory,
createObjectStorageJoinedDevicesResourceFactory,
} from "./joinedDevices";
export { createCouchDBInitialFetchFastForwardResourceFactory } from "./initialFetchFastForward";
@@ -0,0 +1,17 @@
import type { InitialFetchFastForwardResourceFactory } from "@vrtmrz/livesync-commonlib/replication";
import {
LiveSyncCouchDBReplicator,
type LiveSyncCouchDBReplicatorEnv,
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { snapshotRemoteSettings } from "./shared";
/** Create a private CouchDB Replicator for a fresh Fast Fetch checkpoint handoff. */
export function createCouchDBInitialFetchFastForwardResourceFactory(
host: LiveSyncCouchDBReplicatorEnv
): InitialFetchFastForwardResourceFactory {
return (setting) => {
const snapshot = snapshotRemoteSettings(setting);
const replicator = new LiveSyncCouchDBReplicator(host);
return Promise.resolve(replicator.createInitialFetchFastForwardResource(snapshot));
};
}
@@ -0,0 +1,40 @@
import type { RemoteDBSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type {
JoinedDevicesResource,
JoinedDevicesResourceFactory,
JoinedDevicesSnapshot,
ForgetJoinedDeviceResult,
} from "@vrtmrz/livesync-commonlib/replication";
import {
LiveSyncCouchDBReplicator,
type LiveSyncCouchDBReplicatorEnv,
} from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { createReplicatorDisposer, snapshotRemoteSettings, type ResourceReplicator } from "./shared";
interface JoinedDevicesReplicator extends ResourceReplicator {
getJoinedDevices(setting: RemoteDBSettings): Promise<JoinedDevicesSnapshot>;
forgetJoinedDevice(setting: RemoteDBSettings, deviceId: string): Promise<ForgetJoinedDeviceResult>;
}
function createResource(replicator: JoinedDevicesReplicator, snapshot: RemoteDBSettings): JoinedDevicesResource {
return {
read: () => replicator.getJoinedDevices(snapshot),
forget: (deviceId) => replicator.forgetJoinedDevice(snapshot, deviceId),
dispose: createReplicatorDisposer(replicator),
};
}
export function createCouchDBJoinedDevicesResourceFactory(
host: LiveSyncCouchDBReplicatorEnv
): JoinedDevicesResourceFactory {
return (setting) =>
Promise.resolve(createResource(new LiveSyncCouchDBReplicator(host), snapshotRemoteSettings(setting)));
}
export function createObjectStorageJoinedDevicesResourceFactory(
host: LiveSyncCouchDBReplicatorEnv
): JoinedDevicesResourceFactory {
return (setting) =>
Promise.resolve(createResource(new LiveSyncJournalReplicator(host), snapshotRemoteSettings(setting)));
}
+2 -10
View File
@@ -1,3 +1,4 @@
import { MemoryValueStore } from "@vrtmrz/value-store";
import { describe, expect, it } from "vitest";
import {
DEFAULT_SETTINGS,
@@ -18,17 +19,7 @@ import {
} from "./turnSettingsPrivacy";
class MemorySettingService extends SettingService {
readonly items = new Map<string, string>();
saved?: ObsidianLiveSyncSettings;
protected setItem(key: string, value: string) {
this.items.set(key, value);
}
protected getItem(key: string) {
return this.items.get(key) ?? "";
}
protected deleteItem(key: string) {
this.items.delete(key);
}
protected saveData(settings: ObsidianLiveSyncSettings) {
this.saved = structuredClone(settings);
return Promise.resolve();
@@ -70,6 +61,7 @@ describe("managed TURN settings privacy", () => {
const originalURI = ConnectionStringParser.serialize({ type: "p2p", settings: current });
current.remoteConfigurations.managed.uri = originalURI;
const service = new MemorySettingService(new ServiceContext(), {
deviceValues: new MemoryValueStore(),
APIService: {
getSystemVaultName: () => "test-vault",
getAppID: () => "test-app",