diff --git a/packages/core/src/stores/sync-store.test.ts b/packages/core/src/stores/sync-store.test.ts index 3237a744..a9e8737c 100644 --- a/packages/core/src/stores/sync-store.test.ts +++ b/packages/core/src/stores/sync-store.test.ts @@ -457,6 +457,33 @@ describe("useSyncStore", () => { const result = await useSyncStore.getState().syncNow("upload"); expect(syncMocks.runSimpleSync).toHaveBeenCalledWith(mockBackend, expect.any(Function), { + forceUploadSnapshot: true, + fileSyncOptions: { + forceUploadAll: true, + }, + }); + expect(result).toMatchObject({ + success: true, + direction: "upload", + }); + }); + + it("forceFullSync upload forces both database snapshot and file upload", async () => { + useSyncStore.setState({ + config: baseConfig, + isConfigured: true, + backendType: "webdav", + }); + mockPlatformService.kvGetItem.mockImplementation(async (key: string) => + key === "sync_webdav_password" ? "secret" : null, + ); + + const result = await useSyncStore.getState().forceFullSync("upload"); + + expect(syncMocks.runSimpleSync).toHaveBeenCalledWith(mockBackend, expect.any(Function), { + receiveOnly: false, + forceApply: false, + forceUploadSnapshot: true, fileSyncOptions: { forceUploadAll: true, }, diff --git a/packages/core/src/stores/sync-store.ts b/packages/core/src/stores/sync-store.ts index 6c4b358b..57a2e9d0 100644 --- a/packages/core/src/stores/sync-store.ts +++ b/packages/core/src/stores/sync-store.ts @@ -611,6 +611,7 @@ export const useSyncStore = create((set, get) => ({ } : uploadOnly ? { + forceUploadSnapshot: true, fileSyncOptions: { forceUploadAll: true, }, @@ -825,6 +826,7 @@ export const useSyncStore = create((set, get) => ({ disableUploads: true, disableRemoteDeletes: true, }, + ...(direction === "upload" ? { forceUploadSnapshot: true } : {}), }, ); diff --git a/packages/core/src/sync/__tests__/simple-sync.integration.test.ts b/packages/core/src/sync/__tests__/simple-sync.integration.test.ts index e010821f..28ef1626 100644 --- a/packages/core/src/sync/__tests__/simple-sync.integration.test.ts +++ b/packages/core/src/sync/__tests__/simple-sync.integration.test.ts @@ -682,6 +682,38 @@ describe("simple sync convergence", () => { ).toHaveProperty("books"); }); + it("force-uploads a fresh full snapshot even when incremental changes are empty", async () => { + const backend = new MemoryBackend(); + const deviceA = new FakeSyncDb(); + deviceA.syncMetadata.set("last_sync_at", "5000"); + deviceA.insert("books", bookRow({ title: "Latest PC title", updated_at: 1000 })); + + backend.jsonFiles.set("/readany/sync/device-device-a.json", { + deviceId: "device-a", + timestamp: 5900, + since: 0, + tables: { + books: { + records: [bookRow({ title: "Old remote snapshot", updated_at: 1000 })], + deletedIds: [], + }, + }, + }); + + now = 6000; + dbMocks.currentDeviceId = "device-a"; + dbMocks.currentDb = deviceA; + const result = await runSimpleSync(backend, undefined, { forceUploadSnapshot: true }); + + expect(result.success).toBe(true); + const snapshot = backend.jsonFiles.get("/readany/sync/device-device-a.json") as { + timestamp: number; + tables: { books?: { records: Row[] } }; + }; + expect(snapshot.timestamp).toBe(6000); + expect(snapshot.tables.books?.records[0]?.title).toBe("Latest PC title"); + }); + it("downloads remote snapshots using the listed path", async () => { class AliasPathBackend extends MemoryBackend { async listDir(path: string): Promise { diff --git a/packages/core/src/sync/simple-sync.ts b/packages/core/src/sync/simple-sync.ts index aa476a48..b4a52e59 100644 --- a/packages/core/src/sync/simple-sync.ts +++ b/packages/core/src/sync/simple-sync.ts @@ -39,6 +39,8 @@ export interface SimpleSyncOptions { receiveOnly?: boolean; /** When true, bypass timestamp comparisons and force-apply all remote records */ forceApply?: boolean; + /** When true, always upload a fresh full local device snapshot. */ + forceUploadSnapshot?: boolean; fileSyncOptions?: SyncFilesOptions; } @@ -828,7 +830,7 @@ export async function runSimpleSync( ); try { - if (changeCount > 0 || totalApplied > 0) { + if (options.forceUploadSnapshot || changeCount > 0 || totalApplied > 0) { onProgress?.({ phase: "database", operation: "upload",