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 ![](image.jpg)', + 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 ![](image.jpg)' }) + expect(updated.url).toBe(created.url) + expect(stored.map((note) => note.markdown)).toEqual([input.markdown, 'After ![](image.jpg)']) + 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({
+ + )} diff --git a/packages/app-core/src/lib/cloud-auto-sync.test.ts b/packages/app-core/src/lib/cloud-auto-sync.test.ts index c9b142e8..355b5fb2 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.test.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.test.ts @@ -9,6 +9,7 @@ import { acknowledgeCloudConflictResolution, clearCloudSyncStatus, cloudSyncAttentionItems, + cloudSyncAttentionMessage, closeCloudConflictReview, connectCloudAccountFromStatusBar, openCloudConflictReview, @@ -155,6 +156,16 @@ describe("cloud auto sync host wiring", () => { vi.useRealTimers(); }); + it("formats a decimal 10 MB file limit and explains how to recover", () => { + expect(cloudSyncAttentionMessage({ + cursor: 1, pulled: 0, pushed: 0, bootstrap_conflicts: [], local_conflicts: [], + conflicts: [{ operation_id: "op", item_id: "item", code: "FILE_SIZE_LIMIT_EXCEEDED", + current_revision: null, current_path: null, + capacity: { dimension: "sync_max_file_bytes", used: 0, reserved: 0, + limit: 10_000_000, projected: 12_600_000, can_retry_after_reduction: true } }] + })).toBe("A file exceeds the 10 MB Cloud file-size limit. Reduce or remove the oversized file to finish syncing."); + }); + it("keeps the rest of a newly linked review queue when the shared summary is older", () => { const oldSummary: CloudSyncRunSummary = { cursor: 1, pulled: 0, pushed: 0, conflicts: [], bootstrap_conflicts: [], local_conflicts: [], diff --git a/packages/app-core/src/lib/cloud-auto-sync.ts b/packages/app-core/src/lib/cloud-auto-sync.ts index 9855fa6d..6aecc206 100644 --- a/packages/app-core/src/lib/cloud-auto-sync.ts +++ b/packages/app-core/src/lib/cloud-auto-sync.ts @@ -17,6 +17,7 @@ export type CloudAutoSyncBridge = Pick< | "logoutCloudAccount" | "getCloudVaultLink" | "syncCloudVault" + | "hasCloudVaultChanges" | "onCloudSyncWindow" | "onVaultChange" | "onCloudAccountChange" @@ -140,6 +141,13 @@ export function startCloudAutoSync( sync: async () => { await syncCloudVaultWithStatus(bridge); }, + checkRemoteChanges: bridge.hasCloudVaultChanges + ? async () => { + const state = useCloudSyncStatusStore.getState(); + if (!state.vaultName || !isCloudAccountConnectedPhase(state.phase)) return false; + return bridge.hasCloudVaultChanges!(); + } + : undefined, online: environment.online, active: environment.active, debounceMs: timings.debounceMs, @@ -456,7 +464,7 @@ export function cloudSyncAttentionMessage( return `Cloud storage limit reached (${formatCloudBytes(capacity.used + capacity.reserved)} of ${formatCloudBytes(capacity.limit)}). Remove files or increase your Cloud capacity.`; } if (capacity?.dimension === "sync_max_file_bytes") { - return `A file exceeds the ${formatCloudBytes(capacity.limit)} Cloud file-size limit.`; + return `A file exceeds the ${formatCloudBytes(capacity.limit)} Cloud file-size limit. Reduce or remove the oversized file to finish syncing.`; } return "Cloud capacity reached. Remove files or increase your Cloud capacity."; } @@ -600,13 +608,13 @@ export function cloudSyncAttentionItems( } function formatCloudBytes(bytes: number): string { - if (bytes < 1_024) return `${bytes} B`; + if (bytes < 1_000) return `${bytes} B`; const units = ["KB", "MB", "GB", "TB"]; - let value = bytes / 1_024; + let value = bytes / 1_000; let unit = units[0]; for (const candidate of units.slice(1)) { - if (value < 1_024) break; - value /= 1_024; + if (value < 1_000) break; + value /= 1_000; unit = candidate; } return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`; diff --git a/packages/app-core/src/lib/cloud-publishing.test.ts b/packages/app-core/src/lib/cloud-publishing.test.ts index 7363eded..0309cd35 100644 --- a/packages/app-core/src/lib/cloud-publishing.test.ts +++ b/packages/app-core/src/lib/cloud-publishing.test.ts @@ -63,6 +63,35 @@ const note = { } describe('cloud publishing', () => { + it('exposes a discovered public copy without claiming the uncertain request committed', async () => { + const { bridge, createPublishedNote } = setup() + vi.mocked(bridge.listCloudPublishedNotes) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ + id: 42, slug: 'launch', url: 'https://zennotes.org/s/launch', + title: 'Launch', note_path: note.path, created_at: null, updated_at: null + }]) + createPublishedNote.mockRejectedValueOnce(new DOMException('Timed out', 'TimeoutError')) + await expect(publishCloudNote(note, bridge)).rejects.toMatchObject({ + name: 'CloudPublishUnconfirmedError', publicNote: { id: 42 } + }) + expect(createPublishedNote).toHaveBeenCalledOnce() + }) + + it('does not mistake an existing public copy for a successful update after a timeout', async () => { + const { bridge, updateCloudPublishedNote } = setup(true) + updateCloudPublishedNote.mockRejectedValueOnce(new Error('TimeoutError: The operation was aborted due to timeout.')) + await expect(publishCloudNote(note, bridge)).rejects.toThrow('Check the public note') + expect(updateCloudPublishedNote).toHaveBeenCalledOnce() + }) + + it('explains an uncertain create without blindly submitting it again', async () => { + const { bridge, createPublishedNote } = setup() + createPublishedNote.mockRejectedValueOnce(new TypeError('fetch failed')) + await expect(publishCloudNote(note, bridge)).rejects.toThrow('could not be confirmed') + expect(createPublishedNote).toHaveBeenCalledOnce() + }) + it('publishes a note for the first time', async () => { const { bridge, createPublishedNote } = setup() diff --git a/packages/app-core/src/lib/cloud-publishing.ts b/packages/app-core/src/lib/cloud-publishing.ts index 55031d02..e9d354b1 100644 --- a/packages/app-core/src/lib/cloud-publishing.ts +++ b/packages/app-core/src/lib/cloud-publishing.ts @@ -3,6 +3,7 @@ import { getZenBridge } from '@zennotes/bridge-contract/bridge' import type { CloudPublishAppearanceInput, CloudPublishAssetInput, + CloudPublishedNote, CloudPublishedNoteResult, CloudPublishNoteInput } from '@zennotes/bridge-contract/cloud-sync' @@ -30,6 +31,18 @@ export interface CloudPublishOutcome extends CloudPublishedNoteResult { updated: boolean } +export class CloudPublishUnconfirmedError extends Error { + constructor(readonly publicNote: CloudPublishedNote | null, cause: unknown) { + super( + publicNote + ? 'The publishing result could not be confirmed. Check the public note before retrying; its latest changes may already be available.' + : 'The publishing result could not be confirmed. Check Published notes in Settings → Cloud before retrying; the note may already be public.', + { cause } + ) + this.name = 'CloudPublishUnconfirmedError' + } +} + export async function publishCloudNote( note: PublishableCloudNote, bridge: CloudPublishingBridge, @@ -54,11 +67,28 @@ export async function publishCloudNote( ...(assets.length > 0 ? { assets } : {}), ...(appearance === undefined ? {} : { appearance }) } - const result = existing - ? await bridge.updateCloudPublishedNote(existing.id, input) - : await bridge.publishCloudNote(input) + try { + const result = existing + ? await bridge.updateCloudPublishedNote(existing.id, input) + : await bridge.publishCloudNote(input) + return { ...result, updated: existing !== undefined } + } catch (error) { + if (!isUncertainPublishError(error)) throw error + + // Finding a share proves that it is public, not that this request committed. + // Keep the result uncertain and expose the link for verification without + // issuing a duplicate POST or claiming that the latest content is live. + const published = await bridge.listCloudPublishedNotes().catch(() => []) + const publicNote = published.find((candidate) => candidate.note_path === note.path) ?? existing ?? null + throw new CloudPublishUnconfirmedError(publicNote, error) + } +} - return { ...result, updated: existing !== undefined } +function isUncertainPublishError(error: unknown): boolean { + return error instanceof Error && ( + ['TimeoutError', 'AbortError'].includes(error.name) || + /timeout|timed out|fetch failed|failed to fetch|network|socket|ECONNRESET/i.test(error.message) + ) } export async function publishActiveCloudNote( diff --git a/packages/bridge-contract/src/bridge.ts b/packages/bridge-contract/src/bridge.ts index d4735eff..dd361cdf 100644 --- a/packages/bridge-contract/src/bridge.ts +++ b/packages/bridge-contract/src/bridge.ts @@ -155,6 +155,7 @@ export interface ZenBridge { unlinkCloudVault(): Promise deleteCloudVault(): Promise syncCloudVault(): Promise + hasCloudVaultChanges?(): Promise /** Hosts with multiple workspace windows coordinate draft saves before sync. */ onCloudSyncWindow?(handlers: import('./cloud-sync').CloudSyncWindowHandlers): () => void getCloudBootstrapConflict( diff --git a/packages/bridge-contract/src/ipc.ts b/packages/bridge-contract/src/ipc.ts index 3d4b18d2..af58e901 100644 --- a/packages/bridge-contract/src/ipc.ts +++ b/packages/bridge-contract/src/ipc.ts @@ -125,6 +125,7 @@ export const IPC = { CLOUD_VAULT_LINK_DELETE: 'cloud-vault-link:delete', CLOUD_VAULT_DELETE: 'cloud-vault:delete', CLOUD_VAULT_SYNC: 'cloud-vault:sync', + CLOUD_VAULT_HAS_CHANGES: 'cloud-vault:has-changes', CLOUD_VAULT_SYNC_WINDOW: 'cloud-vault:sync-window', CLOUD_VAULT_SYNC_WINDOW_ACK: 'cloud-vault:sync-window-ack', CLOUD_VAULT_CONFLICT_REVIEW_RELEASE: 'cloud-vault:conflict-review-release', diff --git a/packages/shared-domain/src/cloud-auto-sync.test.ts b/packages/shared-domain/src/cloud-auto-sync.test.ts index 211f485d..4467bc1a 100644 --- a/packages/shared-domain/src/cloud-auto-sync.test.ts +++ b/packages/shared-domain/src/cloud-auto-sync.test.ts @@ -26,6 +26,66 @@ async function flushPromises(): Promise { } describe('CloudAutoSyncController', () => { + it('checks for incoming edits every five seconds without running idle vault scans', async () => { + let incoming = false + const checkRemoteChanges = vi.fn(async () => incoming) + const sync = vi.fn(async () => { incoming = false }) + const controller = new CloudAutoSyncController({ ready: () => true, sync, checkRemoteChanges }) + controller.start() + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(5_001) + expect(checkRemoteChanges).toHaveBeenCalledOnce() + expect(sync).toHaveBeenCalledOnce() + incoming = true + await vi.advanceTimersByTimeAsync(5_001) + expect(sync).toHaveBeenCalledTimes(2) + controller.stop() + await vi.advanceTimersByTimeAsync(5_001) + expect(checkRemoteChanges).toHaveBeenCalledTimes(2) + }) + + it('ignores a probe result from a stopped lifecycle after restart', async () => { + let finish!: (changed: boolean) => void + const pending = new Promise((resolve) => { finish = resolve }) + const sync = vi.fn(async () => {}) + const controller = new CloudAutoSyncController({ + ready: () => true, sync, checkRemoteChanges: vi.fn().mockReturnValueOnce(pending).mockResolvedValue(false) + }) + controller.start() + await vi.advanceTimersByTimeAsync(5_001) + controller.stop() + controller.start() + await vi.advanceTimersByTimeAsync(1) + expect(sync).toHaveBeenCalledTimes(2) + finish(true) + await vi.advanceTimersByTimeAsync(1) + expect(sync).toHaveBeenCalledTimes(2) + controller.stop() + }) + + it('does not poll in the background, offline, or during failure backoff', async () => { + let active = false + let online = true + const checkRemoteChanges = vi.fn(async () => { throw new Error('offline') }) + const controller = new CloudAutoSyncController({ + ready: () => true, sync: async () => {}, checkRemoteChanges, + active: () => active, online: () => online, onError: vi.fn() + }) + controller.start() + await vi.advanceTimersByTimeAsync(5_001) + expect(checkRemoteChanges).not.toHaveBeenCalled() + active = true + online = false + await vi.advanceTimersByTimeAsync(5_001) + expect(checkRemoteChanges).not.toHaveBeenCalled() + online = true + await vi.advanceTimersByTimeAsync(5_001) + expect(checkRemoteChanges).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(4_000) + expect(checkRemoteChanges).toHaveBeenCalledOnce() + controller.stop() + }) + beforeEach(() => { vi.useFakeTimers() }) diff --git a/packages/shared-domain/src/cloud-auto-sync.ts b/packages/shared-domain/src/cloud-auto-sync.ts index 018cd6df..8ca44144 100644 --- a/packages/shared-domain/src/cloud-auto-sync.ts +++ b/packages/shared-domain/src/cloud-auto-sync.ts @@ -6,10 +6,13 @@ export type CloudAutoSyncReason = | 'account-change' | 'vault-link' | 'periodic' + | 'remote-change' export interface CloudAutoSyncControllerOptions { ready(): boolean | Promise sync(): Promise + /** A cheap cursor check; only a changed vault needs a full scan and sync. */ + checkRemoteChanges?: () => Promise online?: () => boolean active?: () => boolean debounceMs?: number @@ -26,7 +29,8 @@ const immediateReasons = new Set([ 'online', 'account-change', 'vault-link', - 'periodic' + 'periodic', + 'remote-change' ]) /** @@ -46,6 +50,11 @@ export class CloudAutoSyncController { private runTimer: ReturnType | null = null private runTimerKind: ScheduledRun | null = null private intervalTimer: ReturnType | null = null + private pollTimer: ReturnType | null = null + private polling = false + private pollRetryAt = 0 + private pollRetryAttempt = 0 + private pollGeneration = 0 private pendingImmediate = false private pendingDebounce = false @@ -63,11 +72,16 @@ export class CloudAutoSyncController { if (this.started) return this.started = true this.intervalTimer = setInterval(() => this.request('periodic'), this.intervalMs) + if (this.options.checkRemoteChanges) { + this.pollTimer = setInterval(() => void this.poll(), 5_000) + } this.request('startup') } stop(): void { this.started = false + this.pollGeneration++ + this.polling = false this.pendingImmediate = false this.pendingDebounce = false this.clearRunTimer() @@ -75,6 +89,31 @@ export class CloudAutoSyncController { clearInterval(this.intervalTimer) this.intervalTimer = null } + if (this.pollTimer !== null) { + clearInterval(this.pollTimer) + this.pollTimer = null + } + } + + private async poll(): Promise { + if (!this.started || !this.canRun() || this.running || this.polling || + this.runTimer !== null || Date.now() < this.pollRetryAt) return + this.polling = true + const generation = this.pollGeneration + try { + const changed = await this.options.checkRemoteChanges!() + if (generation !== this.pollGeneration) return + this.pollRetryAttempt = 0 + this.pollRetryAt = 0 + if (changed && this.started) this.request('remote-change') + } catch (error) { + if (generation !== this.pollGeneration) return + const delay = this.retryDelaysMs[Math.min(this.pollRetryAttempt++, this.retryDelaysMs.length - 1)] + this.pollRetryAt = Date.now() + delay + if (this.started) this.options.onError?.(error, delay) + } finally { + if (generation === this.pollGeneration) this.polling = false + } } request(reason: CloudAutoSyncReason): void { @@ -95,7 +134,11 @@ export class CloudAutoSyncController { const lifecycleRetry = ['foreground', 'online', 'account-change', 'vault-link'].includes(reason) if (this.runTimerKind === 'retry' && !lifecycleRetry) return - if (lifecycleRetry) this.retryAttempt = 0 + if (lifecycleRetry) { + this.retryAttempt = 0 + this.pollRetryAttempt = 0 + this.pollRetryAt = 0 + } if (immediate) { this.schedule(0, 'immediate') diff --git a/packages/shared-domain/src/cloud-sync-api.test.ts b/packages/shared-domain/src/cloud-sync-api.test.ts index d6c9ee2d..369fb497 100644 --- a/packages/shared-domain/src/cloud-sync-api.test.ts +++ b/packages/shared-domain/src/cloud-sync-api.test.ts @@ -232,8 +232,8 @@ describe('CloudSyncApiClient', () => { const payload = JSON.stringify({ ...input, tikz_svgs: [], asset_refs: [] }) expect(requests).toEqual([ { method: 'GET', path: '/api/v1/shares' }, - { method: 'POST', path: '/api/v1/shares', body: { payload } }, - { method: 'PUT', path: '/api/v1/shares/42', body: { payload } }, + { method: 'POST', path: '/api/v1/shares', body: { payload }, timeoutMs: 300_000 }, + { method: 'PUT', path: '/api/v1/shares/42', body: { payload }, timeoutMs: 300_000 }, { method: 'DELETE', path: '/api/v1/shares/42' } ]) }) diff --git a/packages/shared-domain/src/cloud-sync-api.ts b/packages/shared-domain/src/cloud-sync-api.ts index ab45b409..d77cce14 100644 --- a/packages/shared-domain/src/cloud-sync-api.ts +++ b/packages/shared-domain/src/cloud-sync-api.ts @@ -69,7 +69,8 @@ export class CloudSyncApiClient { return this.http.request({ method: 'POST', path: '/api/v1/shares', - body: publishedNoteBody(input) + body: publishedNoteBody(input), + timeoutMs: 300_000 }) } @@ -80,7 +81,8 @@ export class CloudSyncApiClient { return this.http.request({ method: 'PUT', path: `/api/v1/shares/${encodeURIComponent(String(shareId))}`, - body: publishedNoteBody(input) + body: publishedNoteBody(input), + timeoutMs: 300_000 }) } From d997f3736d1c0715a753314c2ddf8c7db945c331 Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 14 Sep 2026 09:24:53 -0500 Subject: [PATCH 2/3] fix(editor): refresh restored notes without applying stale reads Compare watcher content with the current buffer instead of a historical save. Invalidate delayed reads after newer watcher events or local writes so Cloud restores refresh open notes while unsaved edits remain protected. --- .../app-core/src/store-note-integrity.test.ts | 86 +++++++++++++++++++ packages/app-core/src/store.ts | 32 +++---- 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/packages/app-core/src/store-note-integrity.test.ts b/packages/app-core/src/store-note-integrity.test.ts index c6b398fb..7adcbd35 100644 --- a/packages/app-core/src/store-note-integrity.test.ts +++ b/packages/app-core/src/store-note-integrity.test.ts @@ -198,6 +198,92 @@ describe('#202 — store keeps each note its own content during navigation', () // editor applied it as a non-undoable doc swap, and persistNote had already // cleared the dirty flag so the follow-up save bailed instead of healing disk. describe('#585 — dirty buffers survive watcher change events', () => { + it('ignores an older watcher read when a later event restores the starting content', async () => { + const { useStore } = await loadStore() + seedRootVault(useStore) + const target = 'index.md' + await useStore.getState().openNoteInPane(useStore.getState().activePaneId, target) + let readStarted!: () => void + const started = new Promise((resolve) => { readStarted = resolve }) + let releaseRead!: () => void + const pending = new Promise((resolve) => { releaseRead = resolve }) + const zen = window.zen as unknown as { readNote: (path: string) => Promise } + let reads = 0 + zen.readNote = async () => { + if (++reads === 1) { + readStarted() + await pending + return { ...meta(target, 'STALE CLOUD CONTENT'), body: 'STALE CLOUD CONTENT' } + } + return { ...meta(target, 'INDEX_BODY'), body: 'INDEX_BODY' } + } + const event = { kind: 'change' as const, path: target, folder: 'inbox' as const, scope: 'content' as const } + const earlier = useStore.getState().applyChange(event) + await started + await useStore.getState().applyChange(event) + releaseRead() + await earlier + + expect(useStore.getState().activeNote?.body).toBe('INDEX_BODY') + expect(useStore.getState().noteContents[target]?.body).toBe('INDEX_BODY') + expect(writeCalls).toEqual([]) + }) + + it.each([ + { saved: 'NEW LOCAL CONTENT', stale: 'INDEX_BODY' }, + { saved: 'INDEX_BODY', stale: 'STALE CLOUD CONTENT' } + ])('ignores a delayed watcher read after saving $saved', async ({ saved, stale }) => { + const { useStore } = await loadStore() + seedRootVault(useStore) + const target = 'index.md' + await useStore.getState().openNoteInPane(useStore.getState().activePaneId, target) + let readStarted!: () => void + const started = new Promise((resolve) => { readStarted = resolve }) + let releaseRead!: () => void + const pending = new Promise((resolve) => { releaseRead = resolve }) + const zen = window.zen as unknown as { readNote: (path: string) => Promise } + zen.readNote = async () => { + readStarted() + await pending + return { ...meta(target, stale), body: stale } + } + const change = useStore.getState().applyChange({ kind: 'change', path: target, folder: 'inbox', scope: 'content' }) + await started + useStore.getState().updateNoteBody(target, 'INTERMEDIATE LOCAL CONTENT') + await useStore.getState().persistNote(target) + useStore.getState().updateNoteBody(target, saved) + await useStore.getState().persistNote(target) + releaseRead() + await change + + expect(useStore.getState().activeNote?.body).toBe(saved) + expect(useStore.getState().noteContents[target]?.body).toBe(saved) + expect(vault.get(target)).toBe(saved) + expect(useStore.getState().noteDirty[target]).toBe(false) + }) + + it.each(['change', 'add'] as const)('refreshes a clean note restored to an earlier local save (%s)', async (kind) => { + const { useStore } = await loadStore() + seedRootVault(useStore) + const target = 'index.md' + await useStore.getState().openNoteInPane(useStore.getState().activePaneId, target) + useStore.getState().updateNoteBody(target, 'SAVED BACKUP CONTENT') + await useStore.getState().persistNote(target) + + // Another device edits the note; restoring a backup later brings back + // bytes this renderer once wrote, but that is no longer a save echo. + vault.set(target, 'NEWER CLOUD CONTENT') + await useStore.getState().applyChange({ kind, path: target, folder: 'inbox', scope: 'content' }) + expect(useStore.getState().noteContents[target]?.body).toBe('NEWER CLOUD CONTENT') + vault.set(target, 'SAVED BACKUP CONTENT') + await useStore.getState().applyChange({ kind, path: target, folder: 'inbox', scope: 'content' }) + + expect(useStore.getState().noteContents[target]?.body).toBe('SAVED BACKUP CONTENT') + expect(useStore.getState().activeNote?.body).toBe('SAVED BACKUP CONTENT') + expect(useStore.getState().noteDirty[target]).toBe(false) + expect(writeCalls).toEqual([{ path: target, body: 'SAVED BACKUP CONTENT' }]) + }) + it('a change event delivering a truncated read never clobbers unsaved edits', async () => { const { useStore } = await loadStore() seedRootVault(useStore) diff --git a/packages/app-core/src/store.ts b/packages/app-core/src/store.ts index b14bc44a..66f38b26 100644 --- a/packages/app-core/src/store.ts +++ b/packages/app-core/src/store.ts @@ -3691,15 +3691,9 @@ const pathSaveTimers = new Map>() * older one to the final rename. */ const pathSaveQueues = new Map>() const PATH_SAVE_DEBOUNCE_MS = 350 - -/** - * The body we most recently wrote to each path. The vault file watcher - * inevitably echoes our own writes back through `applyChange` after a - * short delay — when we recognise the echo (disk body === what we - * wrote) we skip the refresh. Without this, edits made between save - * completion and echo arrival get rolled back to the older disk body. - */ -const lastWrittenByPath = new Map() +// Only the latest watcher read may apply, and a newer local save invalidates +// older reads even if it finishes or returns to the same starting body. +const noteContentVersions = new Map() /** * Old paths of renames the host has not answered yet. A rename is a move on @@ -6559,15 +6553,21 @@ export const useStore = create((set, get) => { // noise left the buffer showing content that no longer existed on disk. if (ev.kind === 'change' || ev.kind === 'add') { try { + const beforeRead = get() + if (beforeRead.noteDirty[ev.path]) return + const bodyBeforeRead = beforeRead.noteContents[ev.path]?.body + const readVersion = (noteContentVersions.get(ev.path) ?? 0) + 1 + noteContentVersions.set(ev.path, readVersion) const content = await window.zen.readNote(ev.path) - // Drop the watcher echo of our own writes. Without this, an - // edit made between save-completion and echo-arrival gets - // overwritten with the older disk body and the user sees - // their last keystroke (often Enter) reverted. - if (lastWrittenByPath.get(ev.path) === content.body) return set((s) => { const existing = s.noteContents[ev.path] - // Ignore noise — only push when disk differs from our buffer. + if ( + s.vault?.root !== beforeRead.vault?.root || + existing?.body !== bodyBeforeRead || + noteContentVersions.get(ev.path) !== readVersion + ) return s + // Compare against the current buffer, not a historical local save: + // a Cloud restore can legitimately bring those old bytes back. if (existing && existing.body === content.body) return s // Never replace a dirty buffer: it holds edits the user has not // saved, and the editor applies this push as a non-undoable doc @@ -6647,7 +6647,7 @@ export const useStore = create((set, get) => { // Snapshot only after earlier writes finish. A second caller sees the // newest buffer here, then becomes the last writer by construction. const writtenBody = content.body - lastWrittenByPath.set(path, writtenBody) + noteContentVersions.set(path, (noteContentVersions.get(path) ?? 0) + 1) const meta = await window.zen.writeNote(path, writtenBody) // Saving a Typst preamble note changes the definitions every note tagged // for it compiles against, so reload and repaint open panes. (#486) From 104416f560bb9e709620cabe9e1f225306f7a4ea Mon Sep 17 00:00:00 2001 From: Adib Hanna Date: Mon, 14 Sep 2026 09:24:59 -0500 Subject: [PATCH 3/3] chore(release): 2.50.0 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 18 +++++++++--------- package.json | 2 +- packages/app-core/package.json | 2 +- packages/bridge-contract/package.json | 2 +- packages/shared-domain/package.json | 2 +- packages/shared-ui/package.json | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f21338c1..1eb3dd8f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/desktop", "productName": "ZenNotes", - "version": "2.49.0", + "version": "2.50.0", "description": "ZenNotes desktop shell", "private": true, "main": "./out/main/index.js", diff --git a/apps/server/package.json b/apps/server/package.json index ed000aa7..b897e3b7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/server", "private": true, - "version": "2.49.0", + "version": "2.50.0", "scripts": { "dev": "node ../../tooling/scripts/run-go-server-dev.mjs", "prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs", diff --git a/apps/web/package.json b/apps/web/package.json index 01558b43..b2825986 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/web", "private": true, - "version": "2.49.0", + "version": "2.50.0", "type": "module", "description": "ZenNotes web client for self-hosted and hosted deployments", "homepage": "https://zennotes.org", diff --git a/package-lock.json b/package-lock.json index c06a6afe..73313a0d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zennotes-monorepo", - "version": "2.49.0", + "version": "2.50.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zennotes-monorepo", - "version": "2.49.0", + "version": "2.50.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -23,7 +23,7 @@ }, "apps/desktop": { "name": "@zennotes/desktop", - "version": "2.49.0", + "version": "2.50.0", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.18.3", @@ -874,11 +874,11 @@ }, "apps/server": { "name": "@zennotes/server", - "version": "2.49.0" + "version": "2.50.0" }, "apps/web": { "name": "@zennotes/web", - "version": "2.49.0", + "version": "2.50.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16286,7 +16286,7 @@ }, "packages/app-core": { "name": "@zennotes/app-core", - "version": "2.49.0", + "version": "2.50.0", "dependencies": { "@codemirror/autocomplete": "^6.18.3", "@codemirror/commands": "^6.7.1", @@ -16363,11 +16363,11 @@ }, "packages/bridge-contract": { "name": "@zennotes/bridge-contract", - "version": "2.49.0" + "version": "2.50.0" }, "packages/shared-domain": { "name": "@zennotes/shared-domain", - "version": "2.49.0", + "version": "2.50.0", "dependencies": { "@zennotes/bridge-contract": "*", "lz-string": "^1.5.0" @@ -16378,7 +16378,7 @@ }, "packages/shared-ui": { "name": "@zennotes/shared-ui", - "version": "2.49.0" + "version": "2.50.0" } } } diff --git a/package.json b/package.json index dd002b4f..37049184 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "zennotes-monorepo", "private": true, - "version": "2.49.0", + "version": "2.50.0", "description": "ZenNotes monorepo for desktop, web, and self-hosted server builds", "packageManager": "npm@10.9.2", "engines": { diff --git a/packages/app-core/package.json b/packages/app-core/package.json index ef2138cc..7fc48020 100644 --- a/packages/app-core/package.json +++ b/packages/app-core/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/app-core", "private": true, - "version": "2.49.0", + "version": "2.50.0", "type": "module", "exports": { "./main": "./src/main.tsx" diff --git a/packages/bridge-contract/package.json b/packages/bridge-contract/package.json index 25e0b592..5b1fbdf4 100644 --- a/packages/bridge-contract/package.json +++ b/packages/bridge-contract/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/bridge-contract", "private": true, - "version": "2.49.0", + "version": "2.50.0", "type": "module", "exports": { "./bridge": "./src/bridge.ts", diff --git a/packages/shared-domain/package.json b/packages/shared-domain/package.json index 3a7f5c9f..e2676c5d 100644 --- a/packages/shared-domain/package.json +++ b/packages/shared-domain/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-domain", "private": true, - "version": "2.49.0", + "version": "2.50.0", "type": "module", "exports": { "./*": "./src/*.ts" diff --git a/packages/shared-ui/package.json b/packages/shared-ui/package.json index cda698d7..9dfe56c7 100644 --- a/packages/shared-ui/package.json +++ b/packages/shared-ui/package.json @@ -1,7 +1,7 @@ { "name": "@zennotes/shared-ui", "private": true, - "version": "2.49.0", + "version": "2.50.0", "type": "module", "exports": { ".": "./src/index.ts"