mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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 in791fa533e7, which was unintentionally removed in0e9218bd68during 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:
parent
d64014d086
commit
1f5184f6e7
7 changed files with 107 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue