From f569626a81fca108a62e3ed0757e3a5dc94f343b Mon Sep 17 00:00:00 2001
From: Adib Hanna
Date: Mon, 14 Sep 2026 09:24:45 -0500
Subject: [PATCH 1/3] fix(cloud): improve publishing recovery and incoming sync
Allow attachment publications to complete beyond 30 seconds, expose uncertain outcomes without claiming success, and make updating a public note explicit in Settings. Detect incoming changes through lightweight cursor checks and show actionable decimal file limits.
---
.../src/main/cloud-publishing-network.test.ts | 47 ++++++++
.../src/main/cloud-sync-service.test.ts | 18 +++
apps/desktop/src/main/cloud-sync-service.ts | 23 +++-
apps/desktop/src/main/index.ts | 3 +
apps/desktop/src/preload/index.ts | 1 +
.../src/components/CloudSettings.test.ts | 105 +++++++++++++++++-
.../app-core/src/components/CloudSettings.tsx | 40 ++++++-
.../src/components/PublishNoteModal.test.ts | 26 +++++
.../src/components/PublishNoteModal.tsx | 20 +++-
.../app-core/src/lib/cloud-auto-sync.test.ts | 11 ++
packages/app-core/src/lib/cloud-auto-sync.ts | 18 ++-
.../app-core/src/lib/cloud-publishing.test.ts | 29 +++++
packages/app-core/src/lib/cloud-publishing.ts | 38 ++++++-
packages/bridge-contract/src/bridge.ts | 1 +
packages/bridge-contract/src/ipc.ts | 1 +
.../shared-domain/src/cloud-auto-sync.test.ts | 60 ++++++++++
packages/shared-domain/src/cloud-auto-sync.ts | 47 +++++++-
.../shared-domain/src/cloud-sync-api.test.ts | 4 +-
packages/shared-domain/src/cloud-sync-api.ts | 6 +-
19 files changed, 476 insertions(+), 22 deletions(-)
create mode 100644 apps/desktop/src/main/cloud-publishing-network.test.ts
diff --git a/apps/desktop/src/main/cloud-publishing-network.test.ts b/apps/desktop/src/main/cloud-publishing-network.test.ts
new file mode 100644
index 00000000..abd60ff1
--- /dev/null
+++ b/apps/desktop/src/main/cloud-publishing-network.test.ts
@@ -0,0 +1,47 @@
+import { createServer } from 'node:http'
+import { once } from 'node:events'
+import { expect, it } from 'vitest'
+import { createCloudSyncClient } from './cloud-sync-client'
+
+it('publishes an attachment when the committed response takes more than 30 seconds, then updates the same link', async () => {
+ const attachment = Buffer.alloc(1_100_000, 73)
+ const stored: { markdown: string; attachment: Buffer }[] = []
+ const server = createServer(async (request, response) => {
+ try {
+ const chunks: Buffer[] = []
+ for await (const chunk of request) chunks.push(Buffer.from(chunk))
+ const form = await new Request('http://localhost/shares', {
+ method: 'POST',
+ headers: { 'Content-Type': request.headers['content-type']! },
+ body: Buffer.concat(chunks)
+ }).formData()
+ const payload = JSON.parse(form.get('payload') as string)
+ const file = form.get('assets[]') as File
+ stored.push({ markdown: payload.markdown, attachment: Buffer.from(await file.arrayBuffer()) })
+ if (request.method === 'POST') await new Promise((resolve) => setTimeout(resolve, 31_000))
+ response.writeHead(request.method === 'POST' ? 201 : 200, { 'Content-Type': 'application/json' })
+ response.end(JSON.stringify({ id: 1, slug: 'test', url: 'http://localhost/s/test' }))
+ } catch (error) {
+ response.writeHead(500)
+ response.end(String(error))
+ }
+ })
+ server.listen(0, '127.0.0.1')
+ await once(server, 'listening')
+ const address = server.address() as import('node:net').AddressInfo
+ const client = createCloudSyncClient(`http://127.0.0.1:${address.port}`, 'test-only')
+ const input = {
+ note_path: 'Cloud test.md', title: 'Cloud test', markdown: 'Before ',
+ assets: [{ ref: 'image.jpg', name: 'image.jpg', mime: 'image/jpeg', base64: attachment.toString('base64') }]
+ }
+ try {
+ const created = await client.publishNote(input)
+ const updated = await client.updatePublishedNote(created.id, { ...input, markdown: 'After ' })
+ expect(updated.url).toBe(created.url)
+ expect(stored.map((note) => note.markdown)).toEqual([input.markdown, 'After '])
+ expect(stored.every((note) => note.attachment.equals(attachment))).toBe(true)
+ } finally {
+ server.closeAllConnections()
+ await new Promise((resolve) => server.close(() => resolve()))
+ }
+}, 45_000)
diff --git a/apps/desktop/src/main/cloud-sync-service.test.ts b/apps/desktop/src/main/cloud-sync-service.test.ts
index f4cfc47f..cb2d64cf 100644
--- a/apps/desktop/src/main/cloud-sync-service.test.ts
+++ b/apps/desktop/src/main/cloud-sync-service.test.ts
@@ -197,6 +197,24 @@ async function setup(
}
describe('DesktopCloudSyncService', () => {
+ it('probes incoming changes without locking windows or advancing the saved cursor', async () => {
+ const prepare = vi.fn(async (_root, run) => run())
+ const vault = { id: 'vault-1', name: 'Notes', cursor: 0, created_at: '2026-09-14T12:00:00Z', updated_at: '2026-09-14T12:00:00Z' }
+ const { service, client, localRoot } = await setup([vault], undefined, undefined, prepare)
+ await service.link(localRoot, vault.id)
+ expect(await service.hasRemoteChanges(localRoot)).toBe(true)
+ await service.sync(localRoot)
+ prepare.mockClear()
+ expect(await service.hasRemoteChanges(localRoot)).toBe(false)
+ client.manifest.mockResolvedValue({ data: [], cursor: 1, next_page: null })
+ expect(await service.hasRemoteChanges(localRoot)).toBe(true)
+ expect(await service.hasRemoteChanges(localRoot)).toBe(true)
+ expect(client.manifest).toHaveBeenLastCalledWith(vault.id, { includeContent: false, perPage: 1 })
+ expect(prepare).not.toHaveBeenCalled()
+ await service.unlink(localRoot)
+ expect(await service.hasRemoteChanges(localRoot)).toBe(false)
+ })
+
it('prepares windows before taking the vault lock and coalesces preparation', async () => {
let prepared!: () => void
const gate = new Promise((resolve) => { prepared = resolve })
diff --git a/apps/desktop/src/main/cloud-sync-service.ts b/apps/desktop/src/main/cloud-sync-service.ts
index f90dfaf5..d1d4e395 100644
--- a/apps/desktop/src/main/cloud-sync-service.ts
+++ b/apps/desktop/src/main/cloud-sync-service.ts
@@ -31,7 +31,7 @@ import {
import { setVaultSettings } from './vault'
import type { CloudSyncApiClient } from '@zennotes/shared-domain/cloud-sync-api'
import { CloudServiceRequestError } from './cloud-sync-client'
-import { createDesktopCloudSyncCoordinator } from './cloud-sync-filesystem'
+import { createDesktopCloudSyncCoordinator, DesktopCloudSyncStateStore } from './cloud-sync-filesystem'
type SyncClient = Pick<
CloudSyncApiClient,
@@ -291,6 +291,27 @@ export class DesktopCloudSyncService {
return { restore, sync }
}
+ async hasRemoteChanges(localRoot: string): Promise {
+ if (this.runs.has(path.resolve(localRoot))) return false
+ const link = await this.readLink(localRoot)
+ if (!link) return false
+ const connection = await this.optionalConnection()
+ if (!connection || link.base_url !== connection.account.base_url) return false
+ const states = new DesktopCloudSyncStateStore(path.join(
+ this.dependencies.storageDirectory, 'states', rootFingerprint(localRoot),
+ fingerprint(connection.account.base_url)
+ ))
+ const state = await states.load(link.vault_id)
+ if (!state) return true
+ // The changes endpoint includes file contents. A one-item manifest exposes
+ // the vault cursor without downloading an attachment just to detect it.
+ const manifest = await connection.client.manifest(link.vault_id, {
+ includeContent: false,
+ perPage: 1
+ })
+ return manifest.cursor !== state.cursor
+ }
+
sync(localRoot: string): Promise {
const runKey = path.resolve(localRoot)
const existing = this.runs.get(runKey)
diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts
index 8787fd7d..8306c90e 100644
--- a/apps/desktop/src/main/index.ts
+++ b/apps/desktop/src/main/index.ts
@@ -2926,6 +2926,9 @@ function registerIpc(): void {
handle(IPC.CLOUD_VAULT_SYNC, () =>
getCloudSyncService().sync(requireLocalCloudVaultRoot()),
);
+ handle(IPC.CLOUD_VAULT_HAS_CHANGES, () =>
+ getCloudSyncService().hasRemoteChanges(requireLocalCloudVaultRoot()),
+ );
on(IPC.CLOUD_VAULT_SYNC_WINDOW_ACK, (event, requestId: unknown, error: unknown) => {
if (typeof requestId !== "string" || requestId.length > 100 ||
(error !== null && (typeof error !== "string" || error.length > 2000))) return;
diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts
index bb943713..13643caa 100644
--- a/apps/desktop/src/preload/index.ts
+++ b/apps/desktop/src/preload/index.ts
@@ -258,6 +258,7 @@ 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),
+ hasCloudVaultChanges: (): Promise => ipcRenderer.invoke(IPC.CLOUD_VAULT_HAS_CHANGES),
onCloudSyncWindow: (handlers: CloudSyncWindowHandlers): (() => void) => {
const active = new Set()
const listener = (_event: Electron.IpcRendererEvent, event: CloudSyncWindowEvent): void => {
diff --git a/packages/app-core/src/components/CloudSettings.test.ts b/packages/app-core/src/components/CloudSettings.test.ts
index 0e201505..037aa2b8 100644
--- a/packages/app-core/src/components/CloudSettings.test.ts
+++ b/packages/app-core/src/components/CloudSettings.test.ts
@@ -8,6 +8,8 @@ import type {
CloudServiceAccount,
CloudSyncRunSummary,
} from "@zennotes/bridge-contract/cloud-sync";
+import { useStore } from "../store";
+import { getPublishNoteRequest, dismissPublishNoteRequest } from "../lib/publish-note-requests";
import { CloudSettings } from "./CloudSettings";
import { subscribePublishedNoteChanges } from "../lib/published-note-events";
import { clearCloudSyncStatus, useCloudSyncStatusStore } from "../lib/cloud-auto-sync";
@@ -20,6 +22,7 @@ const mocks = vi.hoisted(() => ({
getCloudServiceAccount: vi.fn(),
listCloudPublishedNotes: vi.fn(),
unpublishCloudNote: vi.fn(),
+ readNote: vi.fn(),
clipboardWriteText: vi.fn(),
listCloudVaults: vi.fn(),
getCloudVaultLink: vi.fn(),
@@ -222,6 +225,106 @@ describe("CloudSettings", () => {
expect(host.textContent).not.toContain("views");
});
+ it("opens an explicit update with the latest local draft from Published notes", async () => {
+ mocks.getCloudAccountStatus.mockResolvedValue(connected);
+ mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount);
+ mocks.getCloudVaultLink.mockResolvedValue(null);
+ mocks.listCloudVaults.mockResolvedValue([]);
+ mocks.listCloudPublishedNotes.mockResolvedValue([{
+ id: 42, slug: "launch", url: "https://zennotes.org/s/launch",
+ title: "Launch notes", note_path: "Notes/Launch.md", created_at: null, updated_at: null,
+ }]);
+ const draft = { path: "Notes/Launch.md", title: "Launch notes", body: "Newest unsaved draft", assetEmbeds: [] };
+ useStore.setState({ noteContents: { [draft.path]: draft as never }, noteDirty: { [draft.path]: true }, notes: [draft as never] });
+ await act(async () => root.render(createElement(CloudSettings, {
+ localVaultAvailable: true, localVaultName: "Notes",
+ })));
+ expect(host.textContent).toContain("Refresh list");
+ expect(host.textContent).toContain("Edits stay private until you choose Update note");
+ const update = [...host.querySelectorAll("button")].find(b => b.textContent?.trim() === "Update note");
+ expect(update).toBeTruthy();
+ await act(async () => update!.click());
+ expect(getPublishNoteRequest()?.note.body).toBe(draft.body);
+ expect(mocks.readNote).not.toHaveBeenCalled();
+ const request = getPublishNoteRequest();
+ if (request) dismissPublishNoteRequest(request);
+ act(() => useStore.setState({ noteContents: {}, noteDirty: {}, notes: [] }));
+ });
+
+ it("reads the current file instead of a stale clean cache when updating a public note", async () => {
+ mocks.getCloudAccountStatus.mockResolvedValue(connected);
+ mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount);
+ mocks.getCloudVaultLink.mockResolvedValue(null);
+ mocks.listCloudVaults.mockResolvedValue([]);
+ const cached = { path: "Notes/Old.md", title: "Old", body: "Old cache", assetEmbeds: [] };
+ mocks.listCloudPublishedNotes.mockResolvedValue([{
+ id: 43, slug: "old", url: "https://zennotes.org/s/old", title: cached.title,
+ note_path: cached.path, created_at: null, updated_at: null,
+ }]);
+ mocks.readNote.mockResolvedValueOnce({ ...cached, body: "Latest from Cloud" });
+ useStore.setState({ noteContents: { [cached.path]: cached as never }, noteDirty: {}, notes: [cached as never] });
+ await act(async () => root.render(createElement(CloudSettings, {
+ localVaultAvailable: true, localVaultName: "Notes",
+ })));
+ const update = [...host.querySelectorAll("button")].find(b => b.textContent?.trim() === "Update note");
+ await act(async () => update!.click());
+ const request = getPublishNoteRequest();
+ expect(request?.note.body).toBe("Latest from Cloud");
+ if (request) dismissPublishNoteRequest(request);
+ act(() => useStore.setState({ noteContents: {}, noteDirty: {}, notes: [] }));
+ });
+
+ it("prefers a draft edited while the Update note disk read is pending", async () => {
+ mocks.getCloudAccountStatus.mockResolvedValue(connected);
+ mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount);
+ mocks.getCloudVaultLink.mockResolvedValue(null);
+ mocks.listCloudVaults.mockResolvedValue([]);
+ const note = { path: "Notes/Race.md", title: "Race", body: "Old disk content", assetEmbeds: [] };
+ mocks.listCloudPublishedNotes.mockResolvedValue([{
+ id: 43, slug: "race", url: "https://zennotes.org/s/race", title: note.title,
+ note_path: note.path, created_at: null, updated_at: null,
+ }]);
+ let finish!: (value: unknown) => void;
+ mocks.readNote.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; }));
+ useStore.setState({ noteContents: {}, notes: [note as never] });
+ await act(async () => root.render(createElement(CloudSettings, {
+ localVaultAvailable: true, localVaultName: "Notes",
+ })));
+ const update = [...host.querySelectorAll("button")].find(b => b.textContent?.trim() === "Update note");
+ await act(async () => update!.click());
+ await act(async () => {
+ useStore.setState({ noteContents: { [note.path]: { ...note, body: "New draft" } as never }, noteDirty: { [note.path]: true } });
+ finish(note);
+ });
+ const request = getPublishNoteRequest();
+ expect(request?.note.body).toBe("New draft");
+ if (request) dismissPublishNoteRequest(request);
+ act(() => useStore.setState({ noteContents: {}, noteDirty: {}, notes: [] }));
+ });
+
+ it("asks for a smaller file instead of promising automatic recovery from an oversized upload", async () => {
+ mocks.getCloudAccountStatus.mockResolvedValue(connected);
+ mocks.getCloudServiceAccount.mockResolvedValue(serviceAccount);
+ mocks.listCloudVaults.mockResolvedValue([]);
+ mocks.getCloudVaultLink.mockResolvedValue({ base_url: connected.account!.base_url,
+ vault_id: "vault-1", vault_name: "Notes", linked_at: "2026-09-14T12:00:00Z" });
+ mocks.syncCloudVault.mockResolvedValue({ cursor: 1, pulled: 0, pushed: 0,
+ bootstrap_conflicts: [], local_conflicts: [], conflicts: [{
+ operation_id: "large", item_id: "large", code: "FILE_SIZE_LIMIT_EXCEEDED",
+ current_revision: null, current_path: null,
+ capacity: { dimension: "sync_max_file_bytes", limit: 10_000_000, used: 0,
+ reserved: 0, projected: 12_600_000, can_retry_after_reduction: true },
+ }] });
+ await act(async () => root.render(createElement(CloudSettings, {
+ localVaultAvailable: true, localVaultName: "Notes",
+ })));
+ const sync = [...host.querySelectorAll("button")].find(b => b.textContent?.trim() === "Sync now");
+ await act(async () => sync!.click());
+ expect(host.textContent).toContain("10 MB Cloud file-size limit");
+ expect(host.textContent).toContain("Reduce or remove the oversized file");
+ expect(host.textContent).not.toContain("will retry automatically");
+ });
+
it("lists, copies, and unpublishes public notes", async () => {
const publishedNoteChanged = vi.fn();
const unsubscribe = subscribePublishedNoteChanges(publishedNoteChanged);
@@ -733,7 +836,7 @@ describe("CloudSettings", () => {
await act(async () => {
[...host.querySelectorAll("button")]
.find((button) => button.textContent?.trim() === (
- operation === "publishing" ? "Refresh" : "Create backup"
+ operation === "publishing" ? "Refresh list" : "Create backup"
))!.click();
});
expect(host.textContent).toContain(actionError);
diff --git a/packages/app-core/src/components/CloudSettings.tsx b/packages/app-core/src/components/CloudSettings.tsx
index 3320bf73..0294b3f8 100644
--- a/packages/app-core/src/components/CloudSettings.tsx
+++ b/packages/app-core/src/components/CloudSettings.tsx
@@ -26,6 +26,7 @@ import {
} from "../lib/cloud-auto-sync";
import { useToastStore } from "../lib/toast";
import { notifyPublishedNoteChanged } from "../lib/published-note-events";
+import { requestPublishNote } from "../lib/publish-note-requests";
import { Button } from "./ui/Button";
import { useStore } from "../store";
import { CloudPendingConflictResolver } from "./CloudPendingConflictResolver";
@@ -47,6 +48,7 @@ type CloudAction =
| "backup-refresh"
| "publish-refresh"
| "publish-delete"
+ | "publish-update"
| "settings-local"
| "settings-cloud"
| null;
@@ -59,6 +61,7 @@ export function CloudSettings({
localVaultName: string;
}): JSX.Element {
const [bridge] = useState(() => getZenBridge());
+ const localNotes = useStore((state) => state.notes);
const [status, setStatus] = useState(null);
const [serviceAccount, setServiceAccount] =
useState(null);
@@ -430,6 +433,21 @@ export function CloudSettings({
const refreshPublishedNotes = (): Promise =>
runAction("publish-refresh", loadPublishedNotes);
+ const updatePublishedNote = (note: CloudPublishedNote): Promise =>
+ runAction("publish-update", async () => {
+ if (!note.note_path || !localVaultAvailable) return;
+ const store = useStore.getState();
+ const diskOrBuffer = store.noteDirty[note.note_path] && store.noteContents[note.note_path]
+ ? store.noteContents[note.note_path]
+ : await bridge.readNote(note.note_path);
+ const current = useStore.getState();
+ const local = current.noteDirty[note.note_path] && current.noteContents[note.note_path]
+ ? current.noteContents[note.note_path]
+ : diskOrBuffer;
+ store.setSettingsOpen(false);
+ requestPublishNote(local);
+ });
+
const copyPublishedLink = (note: CloudPublishedNote): void => {
bridge.clipboardWriteText(note.url);
useToastStore.getState().addToast("Public link copied.", "success");
@@ -621,6 +639,8 @@ export function CloudSettings({
onOpen={(note) => window.open(note.url, "_blank")}
onRefresh={() => void refreshPublishedNotes()}
onUnpublish={(note) => void unpublishNote(note)}
+ onUpdate={(note) => void updatePublishedNote(note)}
+ canUpdate={(note) => localVaultAvailable && localNotes.some((local) => local.path === note.note_path)}
/>
void;
onRefresh: () => void;
onUnpublish: (note: CloudPublishedNote) => void;
+ onUpdate: (note: CloudPublishedNote) => void;
+ canUpdate: (note: CloudPublishedNote) => boolean;
}): JSX.Element {
if (!publishIncluded) {
return (
@@ -706,13 +730,16 @@ function CloudPublishedNotesPanel({
? `${pluralize(usage.notes, "published note")} · ${pluralize(usage.assets, "asset")} using ${formatBytes(publishedBytes ?? 0)}.`
: "Anyone with a link can view a published note until you unpublish it."}
+
+ Edits stay private until you choose Update note. Refresh list checks published status.
+
@@ -744,6 +771,14 @@ function CloudPublishedNotesPanel({
+