fix(sync): detect legacy v16.x pfapi format and warn user instead of diverging silently

When sync-data.json is missing, check for a legacy __meta_ file before
treating the provider as a fresh start. If found, throw
LegacySyncFormatDetectedError and show an error snack with a force-overwrite
action instead of silently diverging from a v16.x device still writing the
old format.

This restores the behavior added in 791fa533e7, which was unintentionally
removed in 0e9218bd68 during a client-ID refactor. The snack copy clarifies
that force-overwrite only unblocks this device and does not clean up the
legacy files on the remote — if a v16 device syncs again, divergence resumes.

Related: #5964, #6174
This commit is contained in:
Johannes Millan 2026-04-17 16:38:18 +02:00
parent d64014d086
commit 1f5184f6e7
7 changed files with 107 additions and 1 deletions

View file

@ -20,6 +20,7 @@ import {
HttpNotOkAPIError,
EmptyRemoteBodySPError,
JsonParseError,
LegacySyncFormatDetectedError,
SyncDataCorruptedError,
UploadRevToMatchMismatchAPIError,
} from '../../op-log/core/errors/sync-errors';
@ -639,6 +640,22 @@ export class SyncWrapperService {
config: { duration: 12000 },
});
return 'HANDLED_ERROR';
} else if (error instanceof LegacySyncFormatDetectedError) {
// Remote has v16.x pfapi files (__meta_) but no sync-data.json. Usual cause:
// an old device still writing the legacy format, which would silently diverge
// from this client. Force-overwrite is offered as an escape hatch for the
// stale-__meta_ case (successful migration but old files never cleaned up);
// it drops any remaining v16 data in favor of the current local state.
// Issues: #5964, #6174.
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg: T.F.SYNC.S.LEGACY_FORMAT_DETECTED,
type: 'ERROR',
config: { duration: 20000 },
actionFn: async () => this.forceUpload(),
actionStr: T.F.SYNC.S.BTN_FORCE_OVERWRITE,
});
return 'HANDLED_ERROR';
} else if (error instanceof HttpNotOkAPIError && error.response.status === 423) {
// HTTP 423 Locked: WebDAV server holds a file lock.
// Do NOT offer force overwrite — the PUT will also receive 423.

View file

@ -581,3 +581,21 @@ export class SyncDataCorruptedError extends Error {
super(`Sync data incompatible at ${filePath}: ${message}`);
}
}
/**
* Thrown when the remote sync provider has legacy pfapi files (__meta_) but no
* sync-data.json. This means a v16.x client is still writing to the same provider
* using the old per-file format. Cross-version sync is not supported both devices
* must run the same app version for sync to work.
*/
export class LegacySyncFormatDetectedError extends Error {
override name = 'LegacySyncFormatDetectedError';
constructor() {
super(
'Sync format mismatch: the remote storage was last written by an older app version ' +
'(v16.x or earlier) that uses a different sync format. Please update all your ' +
'devices to the same app version so they use the same sync format.',
);
}
}

View file

@ -158,6 +158,10 @@ describe('FileBasedSyncAdapterService', () => {
'getFileRev',
]);
mockProvider.id = SyncProviderId.WebDAV;
// Default: no legacy __meta_ file present → treat missing sync-data.json as fresh start
mockProvider.getFileRev.and.callFake(async (_path: string) => {
throw new RemoteFileNotFoundAPIError('not found');
});
// Clear localStorage to prevent state leaking between tests
// Note: Must clear both old keys (for migration code path) and new atomic key
@ -1707,4 +1711,36 @@ describe('FileBasedSyncAdapterService', () => {
expect(uploadedData.oldestOpSyncVersion).toBe(1);
});
});
describe('legacy pfapi format detection (v16.x cross-version guard)', () => {
it('throws LegacySyncFormatDetectedError when __meta_ exists but sync-data.json does not', async () => {
// Simulate a v16.x provider: __meta_ present, sync-data.json absent
mockProvider.downloadFile.and.rejectWith(
new RemoteFileNotFoundAPIError('not found'),
);
mockProvider.getFileRev.and.callFake(async (path: string) => {
if (path === FILE_BASED_SYNC_CONSTANTS.LEGACY_META_FILE)
return { rev: 'rev-meta' };
throw new RemoteFileNotFoundAPIError('not found');
});
await expectAsync(adapter.downloadOps(0)).toBeRejectedWithError(
/Sync format mismatch/,
);
});
it('treats missing __meta_ as a genuine fresh start (not an error)', async () => {
// Both sync-data.json and __meta_ absent → fresh install
mockProvider.downloadFile.and.callFake(async (_path: string) => {
throw new RemoteFileNotFoundAPIError('not found');
});
mockProvider.getFileRev.and.callFake(async (_path: string) => {
throw new RemoteFileNotFoundAPIError('not found');
});
mockProvider.uploadFile.and.returnValue(Promise.resolve({ rev: 'rev-1' }));
const result = await adapter.downloadOps(0);
expect(result.ops).toEqual([]);
});
});
});

View file

@ -34,6 +34,7 @@ import {
import { OpLog } from '../../../core/log';
import {
InvalidDataSPError,
LegacySyncFormatDetectedError,
RemoteFileNotFoundAPIError,
UploadRevToMatchMismatchAPIError,
} from '../../core/errors/sync-errors';
@ -897,6 +898,12 @@ export class FileBasedSyncAdapterService {
/**
* Downloads and decrypts the sync file.
*
* When sync-data.json is not found, checks for a legacy __meta_ file (written
* by v16.x pfapi clients) and throws LegacySyncFormatDetectedError instead of
* treating the missing file as a fresh start. This prevents silent divergence
* when a new client first syncs to a provider still used by an old client.
*
* @returns The sync data and its revision (ETag) for conditional upload
*/
private async _downloadSyncFile(
@ -904,7 +911,26 @@ export class FileBasedSyncAdapterService {
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
): Promise<{ data: FileBasedSyncData; rev: string }> {
const response = await provider.downloadFile(FILE_BASED_SYNC_CONSTANTS.SYNC_FILE);
let response: Awaited<ReturnType<typeof provider.downloadFile>>;
try {
response = await provider.downloadFile(FILE_BASED_SYNC_CONSTANTS.SYNC_FILE);
} catch (e) {
if (e instanceof RemoteFileNotFoundAPIError) {
// sync-data.json not found. Check for a legacy pfapi __meta_ file before
// treating this as a fresh start — a v16.x device may be writing to the
// same provider, causing silent divergence if we proceed.
let legacyFileFound = false;
try {
await provider.getFileRev(FILE_BASED_SYNC_CONSTANTS.LEGACY_META_FILE, null);
legacyFileFound = true;
} catch (innerE) {
if (!(innerE instanceof RemoteFileNotFoundAPIError)) throw innerE;
// __meta_ not found either → genuine fresh start
}
if (legacyFileFound) throw new LegacySyncFormatDetectedError();
}
throw e;
}
const data =
await this._encryptAndCompressHandler.decompressAndDecryptData<FileBasedSyncData>(
cfg,

View file

@ -124,4 +124,11 @@ export const FILE_BASED_SYNC_CONSTANTS = {
/** Storage key prefix for last known sync version */
SYNC_VERSION_STORAGE_KEY_PREFIX: 'FILE_SYNC_VERSION_',
/**
* Legacy PFAPI metadata file name written by v16.x clients.
* Its presence on a provider (without sync-data.json) signals a version mismatch
* where the old client is still writing and the new client must not silently diverge.
*/
LEGACY_META_FILE: '__meta_',
} as const;

View file

@ -1392,6 +1392,7 @@ const T = {
INTEGRITY_CHECK_FAILED: 'F.SYNC.S.INTEGRITY_CHECK_FAILED',
INVALID_AUTH_CODE: 'F.SYNC.S.INVALID_AUTH_CODE',
INVALID_OPERATION_PAYLOAD: 'F.SYNC.S.INVALID_OPERATION_PAYLOAD',
LEGACY_FORMAT_DETECTED: 'F.SYNC.S.LEGACY_FORMAT_DETECTED',
LOCAL_CHANGES_DISCARDED: 'F.SYNC.S.LOCAL_CHANGES_DISCARDED',
LOCAL_CHANGES_DISCARDED_SNAPSHOT: 'F.SYNC.S.LOCAL_CHANGES_DISCARDED_SNAPSHOT',
LOCAL_DATA_REPLACE_CANCELLED: 'F.SYNC.S.LOCAL_DATA_REPLACE_CANCELLED',

View file

@ -1354,6 +1354,7 @@
"INTEGRITY_CHECK_FAILED": "Data integrity issue detected. Auto-repair attempted. If you experience issues, please reload the app.",
"INVALID_AUTH_CODE": "The authorization code was rejected. Please try authenticating again and make sure to copy the code exactly.",
"INVALID_OPERATION_PAYLOAD": "Invalid operation detected. Changes may not be saved - please reload.",
"LEGACY_FORMAT_DETECTED": "Sync format mismatch: the remote storage was last written by an older app version (v16.x or earlier) that uses a different sync format. Please update all your devices to the same app version. \"Force overwrite\" unblocks this device by writing the new format alongside the legacy files; only use it if no older device will sync to this remote anymore, otherwise they will silently diverge from this device.",
"LOCAL_CHANGES_DISCARDED": "{{count}} local change(s) discarded - item(s) were deleted on another device",
"LOCAL_CHANGES_DISCARDED_SNAPSHOT": "{{count}} unsaved local change(s) discarded - sync downloaded newer data",
"LOCAL_DATA_REPLACE_CANCELLED": "Sync cancelled. Your local data has been preserved.",