diff --git a/frontend/sync-client/src/errors/errors.ts b/frontend/sync-client/src/errors/errors.ts index 523a6aa7..d9efe74e 100644 --- a/frontend/sync-client/src/errors/errors.ts +++ b/frontend/sync-client/src/errors/errors.ts @@ -28,6 +28,11 @@ export class ConflictingPathError extends Error { export class PermanentSyncError extends Error {} +/** + * Retry reconciliation against fresh local state. Raised for stale identity plans + * and local files that change during history recovery. Syncer schedules another + * pass without reporting this as a sync failure. + */ export class LocalChangesDuringReconciliation extends Error {} // The server rejected a checkpoint from a discarded/restored history. diff --git a/frontend/sync-client/src/file-operations/file-change-session.ts b/frontend/sync-client/src/file-operations/file-change-session.ts index 7b5a829e..43ac4ae6 100644 --- a/frontend/sync-client/src/file-operations/file-change-session.ts +++ b/frontend/sync-client/src/file-operations/file-change-session.ts @@ -52,7 +52,7 @@ export class FileChangeSession { return; } - this.options.checkpoint.assertUnchanged(); + this.options.assertUnchanged(); } public beforeFileMutation(): void { @@ -64,7 +64,9 @@ export class FileChangeSession { const { localChanges, isProtectedPath } = this.options; await localChanges.flush(); - this.abortIfStale(); + if (!this.mutationsStarted) { + this.options.assertUnchanged(); + } for (const change of getUnappliedChanges( this.planned, diff --git a/frontend/sync-client/src/file-operations/file-renamer.test.ts b/frontend/sync-client/src/file-operations/file-renamer.test.ts index aaea70ce..fe5bacb7 100644 --- a/frontend/sync-client/src/file-operations/file-renamer.test.ts +++ b/frontend/sync-client/src/file-operations/file-renamer.test.ts @@ -4,7 +4,6 @@ import { test } from "node:test"; import { MemoryDisk } from "../../../test-support/storage"; import { Database, createEmptyDatabase } from "../persistence/database"; import type { ServerConfig } from "../services/server-config"; -import { LocalChangeCheckpoint } from "../sync-operations/local-change-checkpoint"; import { hash } from "../utils/hash"; import { FileOperations } from "./file-operations"; @@ -27,7 +26,6 @@ test("a path swap uses a conflict path only as a temporary destination", async ( async () => undefined ); const files = new FileOperations(disk, database, {} as ServerConfig); - const checkpoint = new LocalChangeCheckpoint({ generation: 0 }); const renames: string[] = []; disk.boundary = (label): void => { if (label.startsWith("visible:rename:")) { @@ -37,7 +35,7 @@ test("a path swap uses a conflict path only as a temporary destination", async ( const next = structuredClone(database.state); next.actualFileManifest = { a: "b.md", b: "a.md" }; - await files.applyChanges(next, checkpoint); + await files.applyChanges(next, () => undefined); assert.deepEqual(renames, [ "b.md->b (conflict b).md", // Free B without losing its contents. diff --git a/frontend/sync-client/src/persistence/database.ts b/frontend/sync-client/src/persistence/database.ts index b08b34f5..6618c209 100644 --- a/frontend/sync-client/src/persistence/database.ts +++ b/frontend/sync-client/src/persistence/database.ts @@ -12,7 +12,7 @@ export interface ContentHead { } export interface DocumentState { - // A null version retains only the clean hash after a server history reset. + // A null version retains an old-history hash until a scan can classify edits. base?: { vaultUpdateId: VaultUpdateId | null; hash: string }; // Last observed disk hash; an empty string means a notification awaits scanning. diff --git a/frontend/sync-client/src/services/server-config.test.ts b/frontend/sync-client/src/services/server-config.test.ts index 410b2b67..dac78a2a 100644 --- a/frontend/sync-client/src/services/server-config.test.ts +++ b/frontend/sync-client/src/services/server-config.test.ts @@ -13,19 +13,23 @@ const config = { mergeableFileExtensions: ["md"], serverVersion: "test" }; -const make = (fetch: typeof globalThis.fetch): ServerConfig => - new ServerConfig( - new SyncService( - "device", - new Settings( - new Logger(), - { remoteUri: "http://test" }, - async () => undefined - ), - fetch, - { get: () => undefined, save: async () => undefined } - ) +const make = (fetch: typeof globalThis.fetch): ServerConfig => { + const service = new SyncService( + "device", + new Settings( + new Logger(), + { remoteUri: "http://test" }, + async () => undefined + ), + fetch, + { + get: (): undefined => undefined, + save: async (): Promise => undefined + } ); + service.resume(); + return new ServerConfig(service); +}; test("configuration shares one validated request and retries failures", async () => { let calls = 0; diff --git a/frontend/sync-client/src/services/server-config.ts b/frontend/sync-client/src/services/server-config.ts index 82e650e0..20adf26a 100644 --- a/frontend/sync-client/src/services/server-config.ts +++ b/frontend/sync-client/src/services/server-config.ts @@ -53,7 +53,7 @@ export class ServerConfig { public async getConfig(): Promise { if (!this.config) { const pending = this.syncService - .getServerConfig() + .getServerConfig(false) .then((config) => { ServerConfig.validateConfig(config); return config; diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index f34a9793..273a4d34 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -46,8 +46,13 @@ export class SyncService { await this.history.save(undefined); } - public async getServerConfig(): Promise { - return this.request("/config", { ignoreAborts: true }); + public async getServerConfig( + ignoreAborts = true + ): Promise { + return this.request("/config", { + ignoreAborts, + recordCheckpoint: false + }); } public async getVaultSnapshot(): Promise { @@ -119,12 +124,14 @@ export class SyncService { { body, ignoreAborts = false, + recordCheckpoint = true, decode = async (response): Promise => // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- Endpoint methods supply the generated protocol response type. response.json() as Promise }: { body?: unknown; ignoreAborts?: boolean; + recordCheckpoint?: boolean; decode?: (response: Response) => Promise; } = {} ): Promise { @@ -169,7 +176,7 @@ export class SyncService { } // Finish metadata saves even if the transport is paused meanwhile. - if (!ignoreAborts) { + if (!ignoreAborts && recordCheckpoint) { await this.recordHistory(response); }