From 06a0f1da47e71911aef0742ea99dab8a84f1f59c Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Thu, 3 Sep 2026 10:49:26 -0500 Subject: [PATCH 01/11] feat: resolve Cloud bootstrap conflicts in place Add guarded local and Cloud comparison with explicit device, Cloud, keep-both, and manual-merge outcomes so first sync no longer depends on a rename workaround.\n\nRefs #683. --- .../src/main/cloud-sync-filesystem.test.ts | 79 +++++ .../desktop/src/main/cloud-sync-filesystem.ts | 55 ++++ .../src/main/cloud-sync-service.test.ts | 61 +++- apps/desktop/src/main/cloud-sync-service.ts | 43 +++ apps/desktop/src/main/index.ts | 18 ++ apps/desktop/src/preload/index.ts | 11 + apps/web/src/bridge/http-bridge.ts | 2 + .../CloudBootstrapConflictResolver.tsx | 275 ++++++++++++++++++ .../src/components/CloudSettings.test.ts | 88 ++++++ .../app-core/src/components/CloudSettings.tsx | 45 ++- .../app-core/src/lib/cloud-auto-sync.test.ts | 1 + packages/app-core/src/lib/cloud-auto-sync.ts | 2 +- packages/bridge-contract/src/bridge.ts | 7 + packages/bridge-contract/src/cloud-sync.ts | 30 ++ packages/bridge-contract/src/ipc.ts | 2 + .../src/cloud-sync-coordinator.test.ts | 211 ++++++++++++++ .../src/cloud-sync-coordinator.ts | 136 +++++++++ .../src/cloud-sync-host-service.test.ts | 75 ++++- .../src/cloud-sync-host-service.ts | 36 +++ .../cloud-sync-portable-filesystem.test.ts | 44 +++ .../src/cloud-sync-portable-filesystem.ts | 54 ++++ 21 files changed, 1268 insertions(+), 7 deletions(-) create mode 100644 packages/app-core/src/components/CloudBootstrapConflictResolver.tsx diff --git a/apps/desktop/src/main/cloud-sync-filesystem.test.ts b/apps/desktop/src/main/cloud-sync-filesystem.test.ts index 76d00c43..c6b17543 100644 --- a/apps/desktop/src/main/cloud-sync-filesystem.test.ts +++ b/apps/desktop/src/main/cloud-sync-filesystem.test.ts @@ -208,6 +208,85 @@ describe('DesktopCloudSyncRepository', () => { expect(await readFile(path.join(root, 'note (cloud conflict).md'), 'utf8')).toBe('remote edit') }) + it('applies an explicit Cloud choice only while the local version is unchanged', async () => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'note.md'), 'local edit') + const repository = new DesktopCloudSyncRepository(root) + const conflict = { + code: 'BOOTSTRAP_CONTENT_CONFLICT' as const, + item_id: 'item-remote', + path: 'note.md', + local_sha256: hash('local edit'), + remote_sha256: hash('cloud edit') + } + + await repository.resolveBootstrapConflict({ + path: 'note.md', + expectedLocalSha256: conflict.local_sha256, + cloudContent: upsert('note.md', 'cloud edit').content!, + resolution: { conflict, choice: 'cloud' } + }) + + expect(await readFile(path.join(root, 'note.md'), 'utf8')).toBe('cloud edit') + await expect( + repository.resolveBootstrapConflict({ + path: 'note.md', + expectedLocalSha256: conflict.local_sha256, + cloudContent: upsert('note.md', 'cloud edit').content!, + resolution: { conflict, choice: 'cloud' } + }) + ).rejects.toThrow('changed on this device') + }) + + it('keeps both bootstrap versions under explicit paths', async () => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'note.md'), 'local edit') + const repository = new DesktopCloudSyncRepository(root) + const conflict = { + code: 'BOOTSTRAP_CONTENT_CONFLICT' as const, + item_id: 'item-remote', + path: 'note.md', + local_sha256: hash('local edit'), + remote_sha256: hash('cloud edit') + } + + await repository.resolveBootstrapConflict({ + path: 'note.md', + expectedLocalSha256: conflict.local_sha256, + cloudContent: upsert('note.md', 'cloud edit').content!, + resolution: { + conflict, + choice: 'both', + keep_both_path: 'note (this device).md' + } + }) + + expect(await readFile(path.join(root, 'note.md'), 'utf8')).toBe('cloud edit') + expect(await readFile(path.join(root, 'note (this device).md'), 'utf8')).toBe('local edit') + }) + + it('writes an explicit merged bootstrap result', async () => { + const root = await temporaryRoot() + await writeFile(path.join(root, 'note.md'), 'local edit') + const repository = new DesktopCloudSyncRepository(root) + const conflict = { + code: 'BOOTSTRAP_CONTENT_CONFLICT' as const, + item_id: 'item-remote', + path: 'note.md', + local_sha256: hash('local edit'), + remote_sha256: hash('cloud edit') + } + + await repository.resolveBootstrapConflict({ + path: 'note.md', + expectedLocalSha256: conflict.local_sha256, + cloudContent: upsert('note.md', 'cloud edit').content!, + resolution: { conflict, choice: 'merged', merged_text: 'merged result' } + }) + + expect(await readFile(path.join(root, 'note.md'), 'utf8')).toBe('merged result') + }) + // What wedged the reporter: the change feed carried a file this device had // never tracked, so sync refused it without ever noticing that the bytes on // disk were already exactly what was being delivered. diff --git a/apps/desktop/src/main/cloud-sync-filesystem.ts b/apps/desktop/src/main/cloud-sync-filesystem.ts index 647553a0..acdcdb24 100644 --- a/apps/desktop/src/main/cloud-sync-filesystem.ts +++ b/apps/desktop/src/main/cloud-sync-filesystem.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto' import { constants as fsConstants, createReadStream, promises as fs } from 'node:fs' import path from 'node:path' import type { + CloudSyncBootstrapConflictResolution, CloudSyncChange, CloudSyncContent, CloudSyncLocalConflict @@ -9,6 +10,7 @@ import type { import { CLOUD_SYNC_SETTINGS_CONFLICT_PATH, CLOUD_SYNC_VAULT_SETTINGS_PATH, + cloudSyncPathKey, cloudSyncConflictCopyPath, isCloudSyncVaultSettingsPath, normalizeCloudSyncPath, @@ -151,6 +153,59 @@ export class DesktopCloudSyncRepository implements CloudSyncRepository { await fs.rename(source, destination) } + async resolveBootstrapConflict(input: { + path: string + expectedLocalSha256: string + cloudContent: CloudSyncContent + resolution: CloudSyncBootstrapConflictResolution + }): Promise { + const current = await this.readIfExists(input.path) + if (!current || sha256(current) !== input.expectedLocalSha256) { + throw new Error( + 'This file changed on this device. Sync again to compare the latest versions.' + ) + } + + if (input.resolution.choice === 'cloud') { + await this.write(input.path, decodeContent(input.cloudContent)) + return + } + + if (input.resolution.choice === 'merged') { + if (input.cloudContent.encoding !== 'utf8' || input.resolution.merged_text === undefined) { + throw new Error('Only text conflicts can be merged.') + } + await this.write(input.path, Buffer.from(input.resolution.merged_text, 'utf8')) + return + } + + if (input.resolution.choice !== 'both') return + if (!input.resolution.keep_both_path) { + throw new Error('Choose a filename for this device’s version.') + } + + const originalPath = normalizeCloudSyncPath(input.path) + const localCopyPath = normalizeCloudSyncPath(input.resolution.keep_both_path) + if ( + !shouldSyncVaultPath(localCopyPath) || + cloudSyncPathKey(localCopyPath) === cloudSyncPathKey(originalPath) + ) { + throw new Error('Choose a different filename inside the synced vault.') + } + + const source = this.resolve(originalPath) + const destination = this.resolve(localCopyPath) + if (await exists(destination)) throw new Error(`${localCopyPath} already exists.`) + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.rename(source, destination) + try { + await this.write(originalPath, decodeContent(input.cloudContent)) + } catch (error) { + await fs.rename(destination, source).catch(() => undefined) + throw error + } + } + private async walk( absoluteDirectory: string, relativeDirectory: string, diff --git a/apps/desktop/src/main/cloud-sync-service.test.ts b/apps/desktop/src/main/cloud-sync-service.test.ts index 0d1a22af..ca3fde34 100644 --- a/apps/desktop/src/main/cloud-sync-service.test.ts +++ b/apps/desktop/src/main/cloud-sync-service.test.ts @@ -1,8 +1,10 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' import os from 'node:os' import path from 'node:path' import type { CloudAccountStatus, + CloudSyncManifestResponse, CloudSyncMutationRequest, CloudSyncVault } from '@zennotes/bridge-contract/cloud-sync' @@ -68,7 +70,13 @@ async function setup( } })), deleteVault: vi.fn(async () => {}), - manifest: vi.fn(async () => ({ data: [], cursor: 0, next_page: null })), + manifest: vi.fn( + async (): Promise => ({ + data: [], + cursor: 0, + next_page: null + }) + ), changes: vi.fn(async () => ({ data: [], cursor: 0, has_more: false })), mutate: vi.fn(async (_vaultId: string, body: CloudSyncMutationRequest) => ({ acknowledged: body.mutations.map((mutation, index) => ({ @@ -296,6 +304,57 @@ describe('DesktopCloudSyncService', () => { expect(result).toMatchObject({ pulled: 0, pushed: 1, conflicts: [] }) }) + it('inspects and resolves a same-path bootstrap conflict through the host service', async () => { + const remoteVault: CloudSyncVault = { + id: 'vault-1', + name: 'Notes', + cursor: 1, + created_at: '2026-08-10T12:00:00.000Z', + updated_at: '2026-08-10T12:00:00.000Z' + } + const { service, client, localRoot } = await setup([remoteVault]) + await service.link(localRoot, remoteVault.id) + await writeFile(path.join(localRoot, 'Note.md'), 'latest local edit') + const cloudText = 'older cloud edit' + const cloudHash = createHash('sha256').update(cloudText).digest('hex') + client.manifest.mockResolvedValue({ + data: [ + { + item_id: 'item-remote', + path: 'Note.md', + kind: 'text', + revision: 3, + sha256: cloudHash, + byte_length: Buffer.byteLength(cloudText), + media_type: 'text/markdown', + content: { + encoding: 'utf8', + data: cloudText, + sha256: cloudHash, + byte_length: Buffer.byteLength(cloudText), + media_type: 'text/markdown' + } + } + ], + cursor: 1, + next_page: null + }) + + const summary = await service.sync(localRoot) + const conflict = summary.bootstrap_conflicts[0]! + await expect(service.getBootstrapConflict(localRoot, conflict)).resolves.toMatchObject({ + local: { text: 'latest local edit' }, + cloud: { text: cloudText } + }) + + await service.resolveBootstrapConflict(localRoot, { conflict, choice: 'cloud' }) + expect(await readFile(path.join(localRoot, 'Note.md'), 'utf8')).toBe(cloudText) + await expect(service.sync(localRoot)).resolves.toMatchObject({ + bootstrap_conflicts: [], + pushed: 0 + }) + }) + it('deletes the remote vault before removing the local device link', async () => { const remoteVault: CloudSyncVault = { id: 'vault-1', diff --git a/apps/desktop/src/main/cloud-sync-service.ts b/apps/desktop/src/main/cloud-sync-service.ts index 07b89c69..50d5979f 100644 --- a/apps/desktop/src/main/cloud-sync-service.ts +++ b/apps/desktop/src/main/cloud-sync-service.ts @@ -12,6 +12,9 @@ import type { CloudPublishedNoteResult, CloudPublishNoteInput, CloudServiceAccount, + CloudSyncBootstrapConflict, + CloudSyncBootstrapConflictDetails, + CloudSyncBootstrapConflictResolution, CloudSyncRunSummary, CloudSyncSettingsChoice, CloudSyncSettingsConflict, @@ -325,6 +328,46 @@ export class DesktopCloudSyncService { } } + async getBootstrapConflict( + localRoot: string, + conflict: CloudSyncBootstrapConflict + ): Promise { + const running = this.runs.get(path.resolve(localRoot)) + if (running) await running + const { account, client, link } = await this.linkedConnection(localRoot) + return await createDesktopCloudSyncCoordinator({ + root: localRoot, + stateDirectory: path.join( + this.dependencies.storageDirectory, + 'states', + rootFingerprint(localRoot), + fingerprint(account.base_url) + ), + vaultId: link.vault_id, + remote: client + }).getBootstrapConflict(conflict) + } + + async resolveBootstrapConflict( + localRoot: string, + resolution: CloudSyncBootstrapConflictResolution + ): Promise { + const running = this.runs.get(path.resolve(localRoot)) + if (running) await running + const { account, client, link } = await this.linkedConnection(localRoot) + await createDesktopCloudSyncCoordinator({ + root: localRoot, + stateDirectory: path.join( + this.dependencies.storageDirectory, + 'states', + rootFingerprint(localRoot), + fingerprint(account.base_url) + ), + vaultId: link.vault_id, + remote: client + }).resolveBootstrapConflict(resolution) + } + /** The pending settings question, if sync parked a cloud version. It lives * in the vault rather than in memory, so closing the app does not answer * it by accident. */ diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 9db6ac09..6b82e895 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -25,6 +25,8 @@ import { createRequire } from "node:module"; import { IPC } from "@shared/ipc"; import type { CloudPublishNoteInput, + CloudSyncBootstrapConflict, + CloudSyncBootstrapConflictResolution, CloudSyncSettingsChoice, } from "@zennotes/bridge-contract/cloud-sync"; import type { @@ -2896,6 +2898,22 @@ function registerIpc(): void { handle(IPC.CLOUD_VAULT_SYNC, () => getCloudSyncService().sync(requireLocalCloudVaultRoot()), ); + handle( + IPC.CLOUD_VAULT_BOOTSTRAP_CONFLICT_GET, + (_event, conflict: CloudSyncBootstrapConflict) => + getCloudSyncService().getBootstrapConflict( + requireLocalCloudVaultRoot(), + conflict, + ), + ); + handle( + IPC.CLOUD_VAULT_BOOTSTRAP_CONFLICT_RESOLVE, + (_event, resolution: CloudSyncBootstrapConflictResolution) => + getCloudSyncService().resolveBootstrapConflict( + requireLocalCloudVaultRoot(), + resolution, + ), + ); handle(IPC.CLOUD_VAULT_SETTINGS_CONFLICT_GET, () => getCloudSyncService().settingsConflict(requireLocalCloudVaultRoot()), ); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index dfb7a65a..22fb2bd3 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -22,6 +22,9 @@ import type { CloudPublishedNoteResult, CloudPublishNoteInput, CloudServiceAccount, + CloudSyncBootstrapConflict, + CloudSyncBootstrapConflictDetails, + CloudSyncBootstrapConflictResolution, CloudSyncRunSummary, CloudSyncSettingsChoice, CloudSyncSettingsConflict, @@ -250,6 +253,14 @@ const api: ZenBridge = { unlinkCloudVault: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_LINK_DELETE), deleteCloudVault: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_DELETE), syncCloudVault: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_SYNC), + getCloudBootstrapConflict: ( + conflict: CloudSyncBootstrapConflict + ): Promise => + ipcRenderer.invoke(IPC.CLOUD_VAULT_BOOTSTRAP_CONFLICT_GET, conflict), + resolveCloudBootstrapConflict: ( + resolution: CloudSyncBootstrapConflictResolution + ): Promise => + ipcRenderer.invoke(IPC.CLOUD_VAULT_BOOTSTRAP_CONFLICT_RESOLVE, resolution), getCloudSettingsConflict: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_SETTINGS_CONFLICT_GET), resolveCloudSettingsConflict: (choice: CloudSyncSettingsChoice): Promise => diff --git a/apps/web/src/bridge/http-bridge.ts b/apps/web/src/bridge/http-bridge.ts index 468e998e..c22f8e80 100644 --- a/apps/web/src/bridge/http-bridge.ts +++ b/apps/web/src/bridge/http-bridge.ts @@ -1410,6 +1410,8 @@ export const httpBridge: ZenBridge = { unlinkCloudVault: async () => notImplemented('unlinkCloudVault'), deleteCloudVault: async () => notImplemented('deleteCloudVault'), syncCloudVault: async () => notImplemented('syncCloudVault'), + getCloudBootstrapConflict: async () => notImplemented('getCloudBootstrapConflict'), + resolveCloudBootstrapConflict: async () => notImplemented('resolveCloudBootstrapConflict'), getCloudSettingsConflict: async () => null, resolveCloudSettingsConflict: async () => notImplemented('resolveCloudSettingsConflict'), listCloudBackups: async () => notImplemented('listCloudBackups'), diff --git a/packages/app-core/src/components/CloudBootstrapConflictResolver.tsx b/packages/app-core/src/components/CloudBootstrapConflictResolver.tsx new file mode 100644 index 00000000..24a9d587 --- /dev/null +++ b/packages/app-core/src/components/CloudBootstrapConflictResolver.tsx @@ -0,0 +1,275 @@ +import { useEffect, useMemo, useState } from "react"; +import type { + CloudSyncBootstrapConflict, + CloudSyncBootstrapConflictDetails, + CloudSyncBootstrapConflictResolution, + CloudSyncRunSummary, +} from "@zennotes/bridge-contract/cloud-sync"; +import { getZenBridge } from "@zennotes/bridge-contract/bridge"; +import { syncCloudVaultWithStatus } from "../lib/cloud-auto-sync"; +import { Button } from "./ui/Button"; + +export function CloudBootstrapConflictResolver({ + conflict, + vaultName, + onResolved, + onClose, +}: { + conflict: CloudSyncBootstrapConflict; + vaultName: string; + onResolved: (summary: CloudSyncRunSummary) => void; + onClose: () => void; +}): JSX.Element { + const [bridge] = useState(() => getZenBridge()); + const [details, setDetails] = + useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [mode, setMode] = useState<"choices" | "both" | "merge">("choices"); + const [keepBothPath, setKeepBothPath] = useState(() => + localCopyPath(conflict.path), + ); + const [mergedText, setMergedText] = useState(""); + + useEffect(() => { + let cancelled = false; + setDetails(null); + setError(null); + void bridge + .getCloudBootstrapConflict(conflict) + .then((next) => { + if (cancelled) return; + setDetails(next); + setMergedText(next.local.text ?? ""); + }) + .catch((cause) => { + if (!cancelled) setError(message(cause)); + }); + return () => { + cancelled = true; + }; + }, [bridge, conflict]); + + const canMerge = Boolean( + details && details.local.text !== null && details.cloud.text !== null, + ); + const titleId = useMemo( + () => `cloud-conflict-${conflict.item_id.replace(/[^a-zA-Z0-9_-]/g, "-")}`, + [conflict.item_id], + ); + + const resolve = async ( + resolution: Omit, + ): Promise => { + setBusy(true); + setError(null); + try { + await bridge.resolveCloudBootstrapConflict({ conflict, ...resolution }); + onResolved(await syncCloudVaultWithStatus(bridge, vaultName)); + } catch (cause) { + setError(message(cause)); + } finally { + setBusy(false); + } + }; + + return ( +
+
+
+

+ Compare versions +

+
+ {conflict.path} +
+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {!details && !error && ( +
+ Loading both versions… +
+ )} + + {details && ( + <> +
+ + +
+ + {mode === "both" && ( +
+ + setKeepBothPath(event.target.value)} + className="mt-2 w-full rounded-lg border border-paper-300 bg-paper-50 px-3 py-2 font-mono text-xs text-ink-900 outline-none focus:border-accent disabled:opacity-50" + /> +
+ + +
+
+ )} + + {mode === "merge" && canMerge && ( +
+ +