Migrate P2P UI and CLI transfers to service views

This commit is contained in:
vorotamoroz
2026-08-31 15:06:39 +00:00
parent 1d2077d3fc
commit 3f76ad796e
8 changed files with 125 additions and 47 deletions
@@ -3,9 +3,13 @@ import P2POpenReplicationPane from "./P2POpenReplicationPane.svelte";
import { mount, unmount } from "svelte";
import type { P2PServiceViews } from "@vrtmrz/livesync-commonlib/p2p";
/**
* Reports action completion so the pane does not infer success merely from a
* settled Promise.
*/
export type P2POpenReplicationModalCallback = {
onSync: (peerId: string) => Promise<void>;
onSyncAndClose: (peerId: string) => Promise<void>;
onSync: (peerId: string) => Promise<boolean>;
onSyncAndClose: (peerId: string) => Promise<boolean>;
};
export class P2POpenReplicationModal extends Modal {
@@ -35,17 +39,20 @@ export class P2POpenReplicationModal extends Modal {
this.rebuildMode = rebuildMode;
}
async onSync(peerId: string) {
async onSync(peerId: string): Promise<boolean> {
if (this.callback?.onSync) {
await this.callback.onSync(peerId);
return await this.callback.onSync(peerId);
}
return false;
}
async onSyncAndClose(peerId: string) {
async onSyncAndClose(peerId: string): Promise<boolean> {
let completed = false;
if (this.callback?.onSyncAndClose) {
await this.callback.onSyncAndClose(peerId);
completed = await this.callback.onSyncAndClose(peerId);
}
this.close();
return completed;
}
override onOpen() {
@@ -16,8 +16,8 @@
interface Props {
p2p: P2PServiceViews;
onSync: (_peerId: string) => Promise<void>;
onSyncAndClose: (_peerId: string) => Promise<void>;
onSync: (_peerId: string) => Promise<boolean>;
onSyncAndClose: (_peerId: string) => Promise<boolean>;
onClose: () => void;
showResult: boolean;
rebuildMode?: boolean;
@@ -49,8 +49,8 @@
try {
syncingPeerId = peerId;
Logger(`Starting sync with ${peerId}`, logLevel);
await onSync(peerId);
Logger(`Sync completed with ${peerId}`, logLevel);
const completed = await onSync(peerId);
if (completed) Logger(`Sync completed with ${peerId}`, logLevel);
} catch (e) {
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
} finally {
@@ -61,8 +61,8 @@
try {
syncingPeerId = peerId;
Logger(`Starting sync with ${peerId}`, logLevel);
await onSyncAndClose(peerId);
Logger(`Sync completed with ${peerId}`, logLevel);
const completed = await onSyncAndClose(peerId);
if (completed) Logger(`Sync completed with ${peerId}`, logLevel);
} catch (e) {
Logger(`Error during sync: ${e instanceof Error ? e.message : String(e)}`, logLevel);
} finally {
@@ -8,7 +8,9 @@ import { P2POpenReplicationModal } from "./P2POpenReplicationModal";
/**
* Creates an openReplicationUI factory for Obsidian environments.
* Returns a per-replicator closure that opens the P2P Replication modal
* and performs bidirectional sync (pull then push on success).
* and performs bidirectional sync (pull then push on success) through the
* stable targeted-transfer view. The compatibility Replicator argument is
* intentionally unused here and remains available only to the rebuild factory.
*
* Usage:
* const factory = createOpenReplicationUI(app);
@@ -17,7 +19,7 @@ import { P2POpenReplicationModal } from "./P2POpenReplicationModal";
export function createOpenReplicationUI(
app: App
): (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) => (showResult: boolean) => Promise<boolean | void> {
return (replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) =>
return (_replicator: LiveSyncTrysteroReplicator, p2p: P2PServiceViews) =>
(showResult: boolean): Promise<boolean | void> => {
const logLevel = showResult ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
return new Promise<boolean | void>((resolve) => {
@@ -37,20 +39,25 @@ export function createOpenReplicationUI(
activeSynchronisations++;
try {
// Pull first, then push only when the pull succeeds.
const pullResult = await replicator.replicateFrom(peerId, showResult);
if (!pullResult?.ok) {
const pullResult = await p2p.targetedTransfer.pullFromPeer(peerId, {
showNotice: showResult,
});
if (pullResult.status !== "completed" || !pullResult.ok) {
sessionResult = false;
return;
return false;
}
const pushResult = await replicator.requestSynchroniseToPeer(peerId);
sessionResult = pushResult?.ok ?? true;
if (sessionResult && closeConnection) await replicator.close();
const pushResult = await p2p.targetedTransfer.requestPushToPeer(peerId);
const completed = pushResult.status === "completed" && pushResult.ok === true;
sessionResult = completed;
if (completed && closeConnection) await p2p.transportLifecycle.disconnect();
return completed;
} catch (e) {
Logger(
`Error in bidirectional sync with ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
logLevel
);
sessionResult = false;
return false;
} finally {
activeSynchronisations--;
settleClosedSession();
@@ -114,12 +121,14 @@ export function createOpenRebuildUI(
Logger(`Rebuilding from peer ${peerId}`, logLevel);
const result = await replicator.replicateFrom(peerId, showResult, true);
sessionResult = result?.ok ?? false;
return sessionResult;
} catch (e) {
Logger(
`Error in rebuild from ${peerId}: ${e instanceof Error ? e.message : String(e)}`,
logLevel
);
sessionResult = false;
return false;
} finally {
try {
replicator.clearOnSetup();
@@ -4,8 +4,8 @@ const modalState = vi.hoisted(() => ({
instances: [] as Array<{
p2p: unknown;
callback: {
onSync: (peerId: string) => Promise<void>;
onSyncAndClose: (peerId: string) => Promise<void>;
onSync: (peerId: string) => Promise<boolean>;
onSyncAndClose: (peerId: string) => Promise<boolean>;
};
onClosed?: () => void;
open: ReturnType<typeof vi.fn>;
@@ -41,8 +41,8 @@ import { createOpenRebuildUI, createOpenReplicationUI } from "./P2PReplicationUI
function createReplicator() {
return {
replicateFrom: vi.fn(async () => ({ ok: true })),
requestSynchroniseToPeer: vi.fn(async () => ({ ok: true })),
replicateFrom: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
requestSynchroniseToPeer: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
close: vi.fn(async () => undefined),
setOnSetup: vi.fn(),
clearOnSetup: vi.fn(),
@@ -50,7 +50,16 @@ function createReplicator() {
}
function createP2PServiceViews() {
return { transportLifecycle: {}, diagnostics: {} } as any;
return {
transportLifecycle: {
disconnect: vi.fn(async () => undefined),
},
targetedTransfer: {
pullFromPeer: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
requestPushToPeer: vi.fn(async () => ({ status: "completed" as const, ok: true as const })),
},
diagnostics: {},
} as any;
}
describe("createOpenReplicationUI", () => {
@@ -72,35 +81,53 @@ describe("createOpenReplicationUI", () => {
it("keeps repeated synchronisation inside the session boundary until the modal closes", async () => {
const replicator = createReplicator();
const session = createOpenReplicationUI({} as any)(replicator, createP2PServiceViews())(true);
const p2p = createP2PServiceViews();
const session = createOpenReplicationUI({} as any)(replicator, p2p)(true);
const modal = modalState.instances[0];
let settled = false;
void session.finally(() => {
settled = true;
});
await modal.callback.onSync("peer-a");
await expect(modal.callback.onSync("peer-a")).resolves.toBe(true);
await Promise.resolve();
expect(settled).toBe(false);
await modal.callback.onSync("peer-b");
expect(replicator.replicateFrom).toHaveBeenCalledTimes(2);
expect(replicator.requestSynchroniseToPeer).toHaveBeenCalledTimes(2);
expect(p2p.targetedTransfer.pullFromPeer).toHaveBeenCalledTimes(2);
expect(p2p.targetedTransfer.requestPushToPeer).toHaveBeenCalledTimes(2);
modal.onClosed?.();
await expect(session).resolves.toBe(true);
});
it("waits for an in-flight synchronisation when the modal closes", async () => {
let finishPull!: (value: { ok: boolean }) => void;
it("routes ordinary peer transfer through the stable targeted-transfer view", async () => {
const replicator = createReplicator();
replicator.replicateFrom.mockImplementation(
const p2p = createP2PServiceViews();
const session = createOpenReplicationUI({} as any)(replicator, p2p)(true);
const modal = modalState.instances[0];
await modal.callback.onSync("peer-a");
modal.onClosed?.();
await expect(session).resolves.toBe(true);
expect(p2p.targetedTransfer.pullFromPeer).toHaveBeenCalledWith("peer-a", { showNotice: true });
expect(p2p.targetedTransfer.requestPushToPeer).toHaveBeenCalledWith("peer-a");
expect(replicator.replicateFrom).not.toHaveBeenCalled();
expect(replicator.requestSynchroniseToPeer).not.toHaveBeenCalled();
});
it("waits for an in-flight synchronisation when the modal closes", async () => {
let finishPull!: (value: { status: "completed"; ok: true }) => void;
const replicator = createReplicator();
const p2p = createP2PServiceViews();
p2p.targetedTransfer.pullFromPeer.mockImplementation(
async () =>
await new Promise<{ ok: boolean }>((resolve) => {
await new Promise<{ status: "completed"; ok: true }>((resolve) => {
finishPull = resolve;
})
);
const session = createOpenReplicationUI({} as any)(replicator, createP2PServiceViews())(true);
const session = createOpenReplicationUI({} as any)(replicator, p2p)(true);
const modal = modalState.instances[0];
let settled = false;
void session.finally(() => {
@@ -113,19 +140,21 @@ describe("createOpenReplicationUI", () => {
expect(settled).toBe(false);
finishPull({ ok: true });
finishPull({ status: "completed", ok: true });
await synchronisation;
await expect(session).resolves.toBe(true);
});
it("closes the P2P connection after a successful sync-and-close action", async () => {
const replicator = createReplicator();
const session = createOpenReplicationUI({} as any)(replicator, createP2PServiceViews())(true);
const p2p = createP2PServiceViews();
const session = createOpenReplicationUI({} as any)(replicator, p2p)(true);
const modal = modalState.instances[0];
await modal.callback.onSyncAndClose("peer-a");
expect(replicator.close).toHaveBeenCalledOnce();
expect(p2p.transportLifecycle.disconnect).toHaveBeenCalledOnce();
expect(replicator.close).not.toHaveBeenCalled();
let settled = false;
void session.finally(() => {
settled = true;
@@ -136,6 +165,20 @@ describe("createOpenReplicationUI", () => {
modal.onClosed?.();
await expect(session).resolves.toBe(true);
});
it("returns a cancelled peer push as non-success to the presentation boundary", async () => {
const replicator = createReplicator();
const p2p = createP2PServiceViews();
p2p.targetedTransfer.requestPushToPeer.mockResolvedValue({ status: "cancelled" } as never);
const session = createOpenReplicationUI({} as any)(replicator, p2p)(true);
const modal = modalState.instances[0];
const actionResult = await modal.callback.onSync("peer-a");
modal.onClosed?.();
expect(actionResult).toBe(false);
await expect(session).resolves.toBe(false);
});
});
describe("createOpenRebuildUI", () => {
@@ -144,11 +187,11 @@ describe("createOpenRebuildUI", () => {
});
it("waits for an in-flight rebuild when the modal closes", async () => {
let finishPull!: (value: { ok: boolean }) => void;
let finishPull!: (value: { status: "completed"; ok: true }) => void;
const replicator = createReplicator();
replicator.replicateFrom.mockImplementation(
async () =>
await new Promise<{ ok: boolean }>((resolve) => {
await new Promise<{ status: "completed"; ok: true }>((resolve) => {
finishPull = resolve;
})
);
@@ -165,8 +208,8 @@ describe("createOpenRebuildUI", () => {
expect(settled).toBe(false);
finishPull({ ok: true });
await rebuild;
finishPull({ status: "completed", ok: true });
await expect(rebuild).resolves.toBe(true);
await expect(session).resolves.toBe(true);
expect(replicator.setOnSetup).toHaveBeenCalledOnce();
expect(replicator.replicateFrom).toHaveBeenCalledWith("peer-a", true, true);