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 a persistent error snack instead of silently diverging from a v16.x device still writing the old format. Related: #6174
This commit is contained in:
parent
667a7986fb
commit
791fa533e7
7 changed files with 100 additions and 1 deletions
|
|
@ -19,6 +19,7 @@ import {
|
|||
MissingRefreshTokenAPIError,
|
||||
HttpNotOkAPIError,
|
||||
EmptyRemoteBodySPError,
|
||||
LegacySyncFormatDetectedError,
|
||||
} from '../../op-log/core/errors/sync-errors';
|
||||
import { MAX_LWW_REUPLOAD_RETRIES } from '../../op-log/core/operation-log.const';
|
||||
import { SyncConfig } from '../../features/config/global-config.model';
|
||||
|
|
@ -672,6 +673,14 @@ export class SyncWrapperService {
|
|||
},
|
||||
});
|
||||
return 'HANDLED_ERROR';
|
||||
} else if (error instanceof LegacySyncFormatDetectedError) {
|
||||
this._providerManager.setSyncStatus('ERROR');
|
||||
this._snackService.open({
|
||||
msg: T.F.SYNC.S.LEGACY_FORMAT_DETECTED,
|
||||
type: 'ERROR',
|
||||
config: { duration: 20000 },
|
||||
});
|
||||
return 'HANDLED_ERROR';
|
||||
} else if (this._isPermissionError(error)) {
|
||||
this._snackService.open({
|
||||
msg: this._getPermissionErrorMessage(),
|
||||
|
|
|
|||
|
|
@ -564,3 +564,21 @@ export class StorageQuotaExceededError extends Error {
|
|||
super('Operation log storage quota exceeded');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,6 +161,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
|
||||
|
|
@ -1879,4 +1883,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';
|
||||
|
|
@ -938,6 +939,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(
|
||||
|
|
@ -945,7 +952,27 @@ 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,
|
||||
|
|
|
|||
|
|
@ -139,4 +139,11 @@ export const FILE_BASED_SYNC_CONSTANTS = {
|
|||
|
||||
/** Base delay in ms for exponential backoff between retries */
|
||||
RETRY_BASE_DELAY_MS: 500,
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
|
|
|||
|
|
@ -1388,6 +1388,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',
|
||||
|
|
|
|||
|
|
@ -1350,6 +1350,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.",
|
||||
"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