fix(sync): isolate file-provider state across target changes (#9063)

* fix(sync): invalidate file-provider target state on config change

Task 2 (sync-simplification plan), core increment. The file adapter keys all
per-target state (sync version, revs, vector clocks, seq cursor, within-cycle
caches) by provider id only, and nothing cleared it on a configuration save. A
provider switch, an account switch behind the same provider id, or an
identity-affecting setting change would reuse the previous target's state
against the new target — reading or writing one target's data against another.

- Extract the delete-all reset into a shared _resetTargetState() over a single
  _targetScopedMaps source of truth; this also closes the one-field gap where
  deleteAllData never cleared _lastRecoveredCorruptRev.
- Add invalidateAllTargets() (+ a target generation counter for the later
  in-flight guard) and call it from WrappedProviderService's existing
  providerConfigChanged$ subscription.

Machine-only token refreshes go through the credential store (setComplete), not
setProviderConfig, so they do not fire providerConfigChanged$ and correctly do
not invalidate.

Remaining Task 2 scope (follow-ups): Electron LocalFile picker + Android
setupSaf ingresses that bypass providerConfigChanged$, and in-flight generation
validation before each remote side effect (incl. the #9023 REPAIR rebase loop).

* fix(sync): invalidate file target on LocalFile picker/SAF change

Task 2 follow-up. The Electron LocalFile folder picker (persists main-side
post-#8228) and Android setupSaf() (writes safFolderUri to the credential store)
change the sync target without going through setProviderConfig(), so they never
fired providerConfigChanged$ — leaving the file adapter's per-target revs/clocks/
caches (keyed only by the unchanged LocalFile provider id) pointed at the old
folder. Route both ingresses through the existing providerConfigChanged$ signal
via a module-level bridge, so WrappedProviderService clears its cache and calls
invalidateAllTargets() exactly as it does for a config save.

Remaining Task 2 scope: in-flight generation validation before each remote side
effect (incl. the #9023 REPAIR rebase loop).

* fix(sync): abort file upload when the target changes mid-operation

Task 2 follow-up (in-flight guard). The file adapter keys per-target state by
provider id, and the same provider object reads live config, so a target switch
(provider/account/folder/identity-affecting setting) DURING an upload would let
the in-flight write commit the previous target's merged data to the new target.

Capture the target generation at the _uploadOps boundary and thread a
write-guarded provider (Proxy over uploadFile/removeFile) through every write
path — single-file, split, REPAIR snapshot, and backups. A generation bump
(invalidateAllTargets) between capture and a write throws FileSyncTargetChangedError
before the write; reads pass through. SyncWrapper maps it to UNKNOWN_OR_CHANGED
(silent self-healing re-sync), like a concurrent-upload rev mismatch.

Residual (documented): a check->write TOCTOU window remains (narrowed, not
closed), and the REPAIR rebaseStaleRepair loop across RejectedOpsHandlerService/
RepairOperationService is not yet generation-threaded (follow-up #2b).

Tests: adapter guard (mid-op abort writes nothing; removeFile guarded; reads
pass), sync-wrapper mapping (silent UNKNOWN_OR_CHANGED).

* fix(sync): extend in-flight target guard to snapshot uploads

Completes the Task 2 in-flight guard. #2a guarded _uploadOps, but the file
adapter has a SECOND remote-write entry point — uploadSnapshot/_uploadSnapshot
(initial/recovery/migration + the #9023 REPAIR snapshot via
_conditionalUploadRepairSnapshot and its backups) — which bypassed _uploadOps
and was left unguarded. Apply the same generation-capture-at-boundary +
write-guarded-provider shadow to _uploadSnapshot.

The REPAIR rebase loop itself needs no guard: rebaseStaleRepair (RepairOperation
Service) performs NO remote I/O — it rebuilds the repair op from local state
(stateSnapshotService + opLogStore.replaceRejectedRepair) and defers the
re-upload to the next sync cycle, which flows through the now-guarded write
paths. deleteAllData's removeFile stays unguarded by design: it is a deliberate
user-initiated wipe of a chosen target, not an in-flight-sync write race.

Test: a target switch during the snapshot's archive-load phase aborts before the
write with FileSyncTargetChangedError.

* fix(sync): guard split-migration writes on the download path

Review follow-up (two independent reviewers). The in-flight target guard covered
the two upload entry points, but the split-format DOWNLOAD path also writes: when
a remote carries a pending split-migration marker, _downloadOpsSplit ->
_resumePendingSplitMigration force-writes the state file, tombstone/.bak, and
migration marker via the RAW provider. A target switch during that resume could
land the previous target's migration on the new one — the same corruption class
the guard prevents.

Apply the same generation-capture-at-boundary shadow to _downloadOps so the
resume writes are guarded; reads (downloadFile/getFileRev) still pass through, so
normal downloads are unaffected.

Test (e2c): a target switch during a pending-migration download aborts the resume
writes with FileSyncTargetChangedError.

* docs(sync): correct Task 2 guard comments; make targetGeneration private

Multi-review cleanup (no behavior change):
- Fix the _targetGeneration doc comment (the in-flight guard it called a
  'follow-up' shipped in the same work; it drives _withTargetGuard now).
- Correct the _targetScopedMaps comment, which overstated the guarantee: a
  mid-download switch can repopulate a cache (read path) and a switch between
  download and upload is caught by neither per-operation guard. What actually
  prevents a cross-target write is the generation guard + the conditional-write
  rev check, which self-heals on the next sync — document that honestly.
- Make the targetGeneration getter private (test-only surface; the guard reads
  the private field directly); specs use bracket access.

* fix(sync): abort a download whose target changed before committing its baseline

Multi-review (Codex) found a data-loss window the write-guard missed: a download
READS target A, and if the target then switches mid-download, staging A's
baseline (sync-version/clock/rev) and letting the caller advance the seq cursor
under the shared provider id makes the NEXT sync skip the new target's ops from a
stale cursor. The write-guard only covers writes; reads pass through.

Capture the generation at the download boundary and, before committing the
baseline (single-file and split paths), abort + reset the target-scoped state if
it changed — SyncWrapper maps the error to a silent self-heal. Closes the
dominant switch-during-download window; a switch after a successful download
(during op-apply / an upload's own cursor commit) is a narrower residual only
per-cycle session capture can fully close (documented).

* fix(sync): map FileSyncTargetChangedError on the force-upload paths

Multi-review (Codex): the error was only mapped in the normal sync() catch. The
forceUpload and USE_LOCAL conflict-resolution catches handled only
EncryptNoPasswordError, so a target switch during a force upload aborted safely
(guard held — no data loss) but surfaced a scary ERROR snack instead of the
silent UNKNOWN_OR_CHANGED self-heal. Add the branch to both force paths.

* refactor(sync): compile-enforce the in-flight guard via a branded provider type

Multi-review (Architecture) hardening: the guard was threaded by an implicit
call-graph convention — a future entry point or write-helper caller that forgot
the _withTargetGuard shadow would silently re-open cross-target writes, the
worst failure class here. Introduce a phantom-branded GuardedFileSyncProvider
(FileSyncProvider & { [unique symbol]: true }) returned by _withTargetGuard and
required by every write-path helper param. Passing a raw provider to a write
path is now a compile error (verified: TS2345). Only createAdapter and the
intentionally-unguarded _deleteAllData keep the raw type. No runtime/behavior
change (brand is a phantom type); adapter suite 135/135.

* fix(sync): invalidate file-adapter state only on real target moves

Task 2's invalidation was wired to "any privateCfg was saved", but its
semantics are "the sync target moved". setProviderConfig() fires
providerConfigChanged$ unconditionally, so two content-only writes
reached invalidateAllTargets():

1. The sync-settings dialog saves with isForce=true, which bypasses the
   JSON-equality dedup, and _updatePrivateConfig then rewrites privateCfg
   unconditionally. So changing the sync interval, toggling compression,
   or pressing Save with nothing changed wiped _localSeqCounters and
   persisted it. A cursor back at 0 makes the next download return a
   snapshotState (isForceFromZero); for a client holding unsynced ops
   that classifies CONCURRENT, and with AUTO_MERGE_CONCURRENT_SNAPSHOT
   false it dead-ends in a binary conflict dialog whose either answer
   discards data. file-based-sync-adapter.service.ts already documented
   this exact hazard at the latestSeq computation.

2. WrappedProviderService fires the GHSA-9544 isEncryptionEnabled
   backfill fire-and-forget on adapter creation. Its setProviderConfig()
   lands within ms (local IO) while the sync it was spawned from is still
   on the network, so it wiped the cursor mid-cycle and aborted that sync
   with FileSyncTargetChangedError even though nothing moved. (The abort
   does not heal the cursor: invalidateAllTargets clears and persists it
   before the abort throws, so the next cycle still bootstraps from 0.)

providerConfigChanged$ now carries isTargetChanged, set from
isSyncTargetChanged(). Every subscriber still drops config-derived caches
(the adapter closes over the resolved encryption key/intent); only a real
move additionally calls invalidateAllTargets(). Both facts ride one
emission so a caller cannot raise a move without the cache drop.

isSyncTargetChanged treats only encryptKey/isEncryptionEnabled as
content-only and everything else as identity-affecting, so an unknown or
newly added field errs toward invalidating rather than reusing one
target's cursor against another. Both directions can lose data; the false
negative is worse only because it is silent.

The OneDrive pre-auth cfg write (dialog-sync-cfg) bypasses
setProviderConfig, so by the time the save's setProviderConfig runs the
diff is a no-op and a folder move would have silently kept the previous
folder's cursor. It now asserts the move directly, gated on a real diff
since it runs on every save. Covered by a test verified to fail without
the fix.

Also correct two comments: invalidateAllTargets' trigger, and the
download-abort's mechanism (a stale cursor suppresses gap detection so
the new target's snapshot is never loaded — it does not skip ops; both
download paths return every op and dedup via appliedOpIds).

Known residual: OneDrive's PROVIDER_FIELD_DEFAULTS are the only ones not
seeded with '', so a config missing one still reports a spurious move on
its first save (one-time, self-correcting). Documented at isUnset.
This commit is contained in:
Johannes Millan 2026-07-16 15:21:16 +02:00 committed by GitHub
parent 71a9a4338a
commit 542da1eb6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1248 additions and 56 deletions

View file

@ -2,6 +2,7 @@
import { T } from '../../../t.const';
import { ConfigFormSection, SyncConfig } from '../global-config.model';
import { SyncProviderId } from '../../../op-log/sync-providers/provider.const';
import { notifyFileProviderTargetChanged } from '../../../op-log/sync-providers/provider-manager.service';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { IS_ELECTRON } from '../../../app.constants';
import {
@ -235,7 +236,16 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
const localProvider = providers.find(
(p) => p.id === SyncProviderId.LocalFile,
);
return (localProvider as LocalFileSyncPicker | undefined)?.pickDirectory();
const pickedPath = await (
localProvider as LocalFileSyncPicker | undefined
)?.pickDirectory();
// The picker persists the folder main-side (post-#8228) without
// going through setProviderConfig, so invalidate file-provider
// target state here (Task 2). Only on an actual pick, not cancel.
if (pickedPath) {
notifyFileProviderTargetChanged();
}
return pickedPath;
},
},
},
@ -267,7 +277,16 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
const localProvider = providers.find(
(p) => p.id === SyncProviderId.LocalFile,
);
return (localProvider as LocalFileSyncPicker | undefined)?.setupSaf();
const safUri = await (
localProvider as LocalFileSyncPicker | undefined
)?.setupSaf();
// setupSaf writes safFolderUri to privateCfg directly (outside
// setProviderConfig), so invalidate file-provider target state
// here (Task 2). setupSaf throws on cancel, so a value = success.
if (safUri) {
notifyFileProviderTargetChanged();
}
return safUri;
},
},
expressions: {

View file

@ -61,6 +61,7 @@ describe('DialogSyncCfgComponent', () => {
mockProviderManager = jasmine.createSpyObj('SyncProviderManager', [
'getProviderById',
'notifyProviderTargetChanged',
]);
mockGlobalConfigService = jasmine.createSpyObj('GlobalConfigService', [], {
@ -799,4 +800,62 @@ describe('DialogSyncCfgComponent', () => {
expect(mockDialogRef.close).toHaveBeenCalled();
});
});
describe('save() — OneDrive pre-auth cfg write (Task 2 target isolation)', () => {
const storedCfg = {
clientId: 'cid',
tenantId: 'common',
syncFolderPath: 'Super Productivity',
accessToken: 'at',
refreshToken: 'rt',
tokenExpiresAt: 1,
};
let setComplete: jasmine.Spy;
const arrangeOneDrive = (formOneDriveCfg: Record<string, unknown>): void => {
setComplete = jasmine.createSpy('setComplete').and.resolveTo(undefined);
mockProviderManager.getProviderById.and.resolveTo({
id: SyncProviderId.OneDrive,
isReady: jasmine.createSpy('isReady').and.resolveTo(true),
privateCfg: {
load: jasmine.createSpy('load').and.resolveTo({ ...storedCfg }),
setComplete,
},
} as any);
mockSyncWrapperService.configuredAuthForSyncProviderIfNecessary.and.resolveTo({
isSuccess: true,
} as any);
(component as any)._tmpUpdatedCfg = {
...(component as any)._tmpUpdatedCfg,
syncProvider: SyncProviderId.OneDrive,
isEnabled: true,
oneDrive: formOneDriveCfg,
};
};
it('signals a target change when the sync folder moves', async () => {
// This write bypasses setProviderConfig(), so by the time the save's later
// setProviderConfig runs, load() already returns the NEW folder and its
// diff is a no-op. Without the explicit signal the previous folder's seq
// cursor/revs/clocks stay keyed under 'OneDrive' and get reused against the
// new folder — the cross-target data loss Task 2 exists to prevent.
arrangeOneDrive({ ...storedCfg, syncFolderPath: 'Elsewhere' });
await component.save();
expect(setComplete).toHaveBeenCalled();
expect(mockProviderManager.notifyProviderTargetChanged).toHaveBeenCalledTimes(1);
});
it('does NOT signal a target change when nothing moved', async () => {
// This runs on EVERY OneDrive save. Signalling unconditionally would wipe
// the seq cursor and dead-end the next sync in a spurious conflict dialog.
arrangeOneDrive({ ...storedCfg });
await component.save();
expect(setComplete).toHaveBeenCalled();
expect(mockProviderManager.notifyProviderTargetChanged).not.toHaveBeenCalled();
});
});
});

View file

@ -38,6 +38,7 @@ import { toSyncProviderId } from '../../../op-log/sync-exports';
import { isFileBasedProviderId } from '../../../op-log/sync/operation-sync.util';
import { SyncLog } from '../../../core/log';
import { SyncProviderManager } from '../../../op-log/sync-providers/provider-manager.service';
import { isSyncTargetChanged } from '../../../op-log/sync-providers/sync-target-identity.util';
import { GlobalConfigService } from '../../../features/config/global-config.service';
import { isOnline } from '../../../util/is-online';
@ -611,6 +612,17 @@ export class DialogSyncCfgComponent implements AfterViewInit {
tokenExpiresAt: identityChanged ? 0 : existingCfg?.tokenExpiresAt,
};
await oneDriveProvider.privateCfg.setComplete(mergedCfg);
// This write bypasses setProviderConfig() (the OAuth flow below reads
// clientId/tenantId straight from the store), so the save's later
// setProviderConfig sees THIS cfg on both sides of its diff and can't
// infer the move — `syncFolderPath` would change while the old folder's
// cursor stays keyed under 'OneDrive'. Raise the signal here instead.
// Gated because this runs on EVERY save: an unconditional notify would
// wipe the seq cursor on a no-op save. See `providerConfigChanged$`.
if (isSyncTargetChanged(existingCfg, mergedCfg)) {
this._providerManager.notifyProviderTargetChanged();
}
}
}

View file

@ -43,6 +43,7 @@ import {
ForceUploadPendingOpsError,
HttpNotOkAPIError,
IncompleteRemoteOperationsError,
FileSyncTargetChangedError,
} from '../../op-log/core/errors/sync-errors';
import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password/dialog-enter-encryption-password.component';
import { MAX_LWW_REUPLOAD_RETRIES } from '../../op-log/core/operation-log.const';
@ -1238,6 +1239,25 @@ describe('SyncWrapperService', () => {
);
});
it('should handle FileSyncTargetChangedError as a silent self-healing re-sync', async () => {
// The file target changed mid-upload; the guarded write was abandoned.
// This is benign — the next sync re-reads/re-uploads against the current
// target — so it must report UNKNOWN_OR_CHANGED with no error snackbar.
mockSyncService.uploadPendingOps.and.returnValue(
Promise.reject(new FileSyncTargetChangedError(0, 1)),
);
const result = await service.sync(true);
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
'UNKNOWN_OR_CHANGED',
);
expect(mockSnackService.open).not.toHaveBeenCalledWith(
jasmine.objectContaining({ type: 'ERROR' }),
);
});
it('should handle NetworkUnavailableSPError silently for automatic syncs', async () => {
// The auto-sync fired on Android resume hits a not-yet-ready network and
// throws this transient error; the next cycle retries, so no snack should
@ -1855,6 +1875,32 @@ describe('SyncWrapperService', () => {
);
});
it('self-heals silently when the target changes during USE_LOCAL force upload', async () => {
const conflictError = new LocalDataConflictError(
2,
{ tasks: [] },
{ clientB: 3 },
);
mockSyncService.downloadRemoteOps.and.returnValue(Promise.reject(conflictError));
mockMatDialog.open.and.returnValue({
afterClosed: () => of('USE_LOCAL'),
} as any);
mockSyncService.forceUploadLocalState = jasmine
.createSpy('forceUploadLocalState')
.and.rejectWith(new FileSyncTargetChangedError(0, 1));
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith(
'UNKNOWN_OR_CHANGED',
);
// Benign target switch → no error snackbar.
expect(mockSnackService.open).not.toHaveBeenCalledWith(
jasmine.objectContaining({ type: 'ERROR' }),
);
});
it('should translate a typed force-upload failure during conflict resolution', async () => {
const conflictError = new LocalDataConflictError(
2,

View file

@ -26,6 +26,7 @@ import {
UploadRevToMatchMismatchAPIError,
ForceUploadFailedError,
ForceUploadPendingOpsError,
FileSyncTargetChangedError,
} 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';
@ -954,6 +955,17 @@ export class SyncWrapperService {
);
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
} else if (error instanceof FileSyncTargetChangedError) {
// The file sync target (provider/account/folder) changed while this
// upload was in flight; the guarded write was abandoned before it could
// land the previous target's data on the new one — self-healing. Do not
// show an error snackbar; UNKNOWN_OR_CHANGED triggers the next sync,
// which re-reads and re-uploads against the current target from zero.
SyncLog.log(
'SyncWrapperService: Sync target changed mid-operation, will re-sync against the current target',
);
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
} else if (error instanceof OperationIntegrityError) {
// A decrypted op's unauthenticated metadata contradicted its authenticated
// payload, or a plaintext op arrived while encryption is mandatory
@ -1079,11 +1091,20 @@ export class SyncWrapperService {
);
SyncLog.log('SyncWrapperService: Force upload complete');
} catch (error) {
// GHSA-9544-hjjr-fg8h: a keyless-but-encryption-enabled provider makes
// force upload refuse to send plaintext. Route to the enter-password
// recovery dialog like the main sync path, not a dead-end error snack —
// otherwise "force overwrite" (offered as the lost-key recovery) loops.
if (error instanceof EncryptNoPasswordError) {
if (error instanceof FileSyncTargetChangedError) {
// The file target switched during the force upload; the guarded write
// was abandoned before it could hit the new target. Self-healing like
// the main sync path — report UNKNOWN_OR_CHANGED (no error snack) so
// the next sync re-reads/re-uploads against the current target.
SyncLog.log(
'SyncWrapperService: target changed mid-force-upload, will re-sync against the current target',
);
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
} else if (error instanceof EncryptNoPasswordError) {
// GHSA-9544-hjjr-fg8h: a keyless-but-encryption-enabled provider makes
// force upload refuse to send plaintext. Route to the enter-password
// recovery dialog like the main sync path, not a dead-end error snack —
// otherwise "force overwrite" (offered as the lost-key recovery) loops.
this._handleMissingPasswordDialog();
} else {
SyncLog.err('SyncWrapperService: Force upload failed:', error);
@ -1404,6 +1425,16 @@ export class SyncWrapperService {
return 'HANDLED_ERROR';
}
} catch (resolutionError) {
if (resolutionError instanceof FileSyncTargetChangedError) {
// Target switched during USE_LOCAL force upload; the guarded write was
// abandoned. Self-heal silently (UNKNOWN_OR_CHANGED) like the main sync
// path instead of a dead-end error snack.
SyncLog.log(
'SyncWrapperService: target changed mid-conflict-resolution, will re-sync against the current target',
);
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
}
// GHSA-9544-hjjr-fg8h: USE_LOCAL force-uploads, which refuses to send
// plaintext when the key is missing. Route to the enter-password recovery
// dialog instead of a dead-end error snack (mirrors the main sync path).

View file

@ -122,6 +122,25 @@ export class ForceUploadPendingOpsError extends Error {
override name = 'ForceUploadPendingOpsError';
}
/**
* The file-sync target changed (provider switch, account switch behind the same
* provider id, or an identity-affecting config/folder change) while a file
* upload was in flight detected by a bumped adapter target generation before a
* remote write. The in-flight write carries the previous target's merged data,
* so it is abandoned rather than committed to the new target. The next sync
* re-reads and re-uploads against the current target from zero. Transient by
* design; not a corruption. (Task 2, docs/plans/2026-07-13-sync-simplification-plan.md.)
*/
export class FileSyncTargetChangedError extends Error {
override name = 'FileSyncTargetChangedError';
constructor(capturedGeneration: number, currentGeneration: number) {
super(
`File sync target changed mid-operation (generation ${capturedGeneration}${currentGeneration}); write abandoned.`,
);
}
}
/**
* A deferred action can never be persisted (invalid entity identifiers or an
* invalid operation payload) a deterministic condition, not a transient

View file

@ -14,6 +14,7 @@ import {
} from './file-based-sync.types';
import {
EncryptNoPasswordError,
FileSyncTargetChangedError,
InvalidDataSPError,
RemoteFileNotFoundAPIError,
SplitSyncFormatDetectedError,
@ -232,6 +233,104 @@ describe('FileBasedSyncAdapterService', () => {
});
});
describe('in-flight target guard (Task 2)', () => {
it('aborts the upload without writing when the target changes mid-operation', async () => {
// The first download happens inside the upload (to read current state).
// Simulate a concurrent provider/account/folder switch during it, then
// report the file as absent so the upload would otherwise create it.
mockProvider.downloadFile.and.callFake(async () => {
service.invalidateAllTargets(); // bumps the target generation mid-op
throw new RemoteFileNotFoundAPIError('sync-data.json');
});
mockProvider.uploadFile.and.returnValue(Promise.resolve({ rev: 'rev-1' }));
await expectAsync(
adapter.uploadOps([createMockSyncOp()], 'client1'),
).toBeRejectedWithError(FileSyncTargetChangedError);
// The critical assertion: no write reached the (now different) target.
expect(mockProvider.uploadFile).not.toHaveBeenCalled();
});
it('lets a normal upload write when the generation is stable', async () => {
mockProvider.downloadFile.and.throwError(
new RemoteFileNotFoundAPIError('sync-data.json'),
);
mockProvider.uploadFile.and.returnValue(Promise.resolve({ rev: 'rev-1' }));
await adapter.uploadOps([createMockSyncOp()], 'client1');
expect(mockProvider.uploadFile).toHaveBeenCalled();
});
it('aborts a download before committing its baseline when the target changes mid-download', async () => {
// The download reads target A; a switch during that read must not let the
// stale baseline (sync-version/clock/rev) or its seq cursor be committed
// under the shared provider id — otherwise the next sync skips the new
// target's ops from a stale cursor (data loss).
const syncData = createMockSyncData({ syncVersion: 5, vectorClock: { c1: 5 } });
mockProvider.downloadFile.and.callFake(async () => {
service.invalidateAllTargets(); // target switch mid-download
return { dataStr: addPrefix(syncData), rev: 'rev-A' };
});
await expectAsync(adapter.downloadOps(0)).toBeRejectedWithError(
FileSyncTargetChangedError,
);
// Baseline was not committed: the seq cursor stayed at zero.
expect(await adapter.getLastServerSeq()).toBe(0);
});
it('guards removeFile as well as uploadFile, and passes reads through', async () => {
const guarded = service['_withTargetGuard'](
mockProvider,
service['_targetGeneration'],
);
// Reads always pass through.
await guarded.downloadFile('any');
expect(mockProvider.downloadFile).toHaveBeenCalled();
// A target change after capture makes both writes throw.
service.invalidateAllTargets();
await expectAsync(guarded.uploadFile('p', 'd', null, true)).toBeRejectedWithError(
FileSyncTargetChangedError,
);
await expectAsync(guarded.removeFile('p')).toBeRejectedWithError(
FileSyncTargetChangedError,
);
expect(mockProvider.uploadFile).not.toHaveBeenCalled();
expect(mockProvider.removeFile).not.toHaveBeenCalled();
});
it('aborts a snapshot upload without writing when the target changes mid-operation', async () => {
// uploadSnapshot is the second write entry point (initial/recovery/
// migration + REPAIR). Loading archive data happens after the operation
// captures its generation but before the snapshot write, so bump the
// generation there to simulate a concurrent target switch.
mockArchiveDbAdapter.loadArchiveYoung.and.callFake(async () => {
service.invalidateAllTargets();
return mockArchiveYoung;
});
mockProvider.uploadFile.and.returnValue(Promise.resolve({ rev: 'rev-1' }));
await expectAsync(
adapter.uploadSnapshot(
{ tasks: [] },
'client1',
'initial',
{ client1: 1 },
2,
undefined, // isPayloadEncrypted
'op-1', // opId
),
).toBeRejectedWithError(FileSyncTargetChangedError);
expect(mockProvider.uploadFile).not.toHaveBeenCalled();
});
});
describe('uploadOps', () => {
it('should return empty results when no ops to upload and file exists', async () => {
// Mock that a sync file already exists
@ -1336,6 +1435,36 @@ describe('FileBasedSyncAdapterService', () => {
});
});
describe('invalidateAllTargets', () => {
it('clears target-scoped state so the next sync full-reads the current target', async () => {
// Establish per-target state the way a normal sync cycle would.
await adapter.setLastServerSeq(50);
expect(await adapter.getLastServerSeq()).toBe(50);
// A real target move (provider switch, account switch behind the same
// provider id, or a folder/URL change) must invalidate the
// provider-id-keyed state, or it is reused against the new target and can
// read/write one target's data against another.
service.invalidateAllTargets();
// NOTE: resetting the cursor to 0 is correct ONLY for a real target move.
// On a save that left the target put, a 0 cursor makes the next download
// return a snapshotState (isForceFromZero), which for a client holding
// unsynced ops dead-ends in a binary conflict dialog — see the
// latestSeq/snapshotState suite below. Hence invalidateAllTargets() rides
// providerTargetChanged$, never providerConfigChanged$; that wiring is
// covered in wrapped-provider.service.spec.ts.
expect(await adapter.getLastServerSeq()).toBe(0);
});
it('advances the target generation on each invalidation', () => {
const before = service['_targetGeneration'];
service.invalidateAllTargets();
service.invalidateAllTargets();
expect(service['_targetGeneration']).toBe(before + 2);
});
});
describe('latestSeq and snapshotState behavior', () => {
it('should return latestSeq based on syncVersion (not recentOps.length) to prevent repeated fresh downloads', async () => {
// This test ensures that after a snapshot upload (where recentOps is empty),
@ -3743,6 +3872,53 @@ describe('FileBasedSyncAdapterService', () => {
expect(finalizedMarker).toBeDefined();
});
it('(e2c) aborts a pending split migration resume without writing when the target changes mid-download', async () => {
// The split-format DOWNLOAD path resumes a crashed migration by writing
// remote files. Task 2's in-flight guard must cover it too: a target
// switch during the download must abort those writes.
const clock = { client1: 7 };
const legacy = createMockSyncData({
syncVersion: 7,
vectorClock: clock,
state: { tasks: ['legacy'] },
});
const pendingOps = makeOpsFile({
syncVersion: 7,
vectorClock: clock,
snapshotRef: { syncVersion: 7, vectorClock: clock },
migration: { status: 'pending', legacyRev: `${C.SYNC_FILE}-rev` },
});
const map: Record<string, string> = {
[C.SYNC_FILE]: addPrefix(legacy, 2),
[C.OPS_FILE]: addPrefix(pendingOps, 3),
[C.STATE_FILE]: addPrefix(
makeStateFile({
syncVersion: 7,
vectorClock: clock,
state: { tasks: ['legacy'] },
}),
3,
),
};
// Simulate a concurrent target switch during the download (before the
// migration-resume writes) by bumping the generation on the first read.
let bumped = false;
mockProvider.downloadFile.and.callFake(async (path: string) => {
if (!bumped) {
bumped = true;
service.invalidateAllTargets();
}
if (path in map) return { dataStr: map[path], rev: `${path}-rev` };
throw new RemoteFileNotFoundAPIError(path);
});
await expectAsync(adapter.downloadOps(0, 'client2')).toBeRejectedWithError(
FileSyncTargetChangedError,
);
// No migration-resume write reached the (now switched) target.
expect(mockProvider.uploadFile).not.toHaveBeenCalled();
});
it('(e3) retries migration from a newer legacy revision instead of tombstoning over it', async () => {
const legacyV7 = createMockSyncData({
syncVersion: 7,

View file

@ -34,6 +34,7 @@ import { OpLog } from '../../../core/log';
import {
DecompressError,
DecryptError,
FileSyncTargetChangedError,
InvalidDataSPError,
JsonParseError,
LegacySyncFormatDetectedError,
@ -84,6 +85,22 @@ import {
*
* @see FileBasedSyncData for the file schema
*/
declare const GUARDED_PROVIDER_BRAND: unique symbol;
/**
* A file provider whose `uploadFile`/`removeFile` are wrapped by
* `_withTargetGuard` a write aborts if the target changed mid-operation. Every
* write-path helper takes this branded type instead of the raw provider, so the
* compiler rejects passing an unguarded provider to a write path: the in-flight
* guard invariant is enforced at compile time, not by call-graph convention (a
* future entry point or helper that forgets the guard is a build error, not a
* silent cross-target write). Only the raw entry points `createAdapter` and the
* intentionally-unguarded `_deleteAllData` keep `FileSyncProvider`. (Task 2.)
*/
type GuardedFileSyncProvider = FileSyncProvider<SyncProviderId> & {
readonly [GUARDED_PROVIDER_BRAND]: true;
};
@Injectable({ providedIn: 'root' })
export class FileBasedSyncAdapterService {
private _encryptAndCompressHandler = new EncryptAndCompressHandlerService();
@ -100,6 +117,18 @@ export class FileBasedSyncAdapterService {
*/
private readonly _MAX_UPLOAD_RETRIES = 2;
/**
* Monotonic in-tab target-transition generation. Bumped by
* `invalidateAllTargets()` on any user-authoritative provider/target/
* configuration change. Captured at each remote-operation boundary
* (`_uploadOps`/`_uploadSnapshot`/`_downloadOps`) and checked by
* `_withTargetGuard` before every write, so an in-flight operation refuses a
* remote side effect against a target that is no longer current. Not
* persisted (a fresh tab starts clean; there is nothing in-flight to guard).
* (Task 2, docs/plans/2026-07-13-sync-simplification-plan.md.)
*/
private _targetGeneration = 0;
/** Expected sync version for optimistic locking, keyed by provider+user */
private _expectedSyncVersions = new Map<string, number>();
@ -322,6 +351,173 @@ export class FileBasedSyncAdapterService {
this._syncCycleCache.delete(providerKey);
}
/**
* Single source of truth for the target-scoped state maps everything keyed
* by provider id that belongs to one remote target and must not survive a
* target transition. Both `_resetTargetState` (single key, used by
* `deleteAllData`) and `invalidateAllTargets` (all keys) iterate this list, so
* a new per-target map only has to be added here. The two within-cycle caches
* are included so a subsequent sync starts clean.
*
* Note: clearing these maps is not by itself what prevents a cross-target
* write. A download already in flight when the switch fires can repopulate a
* cache (a read path `_setCachedSyncData` inside `_downloadOps`), and a
* switch landing entirely between the download and the upload is caught by
* neither per-operation guard. What actually blocks committing one target's
* data to another is the in-flight generation guard (`_withTargetGuard`) plus
* the conditional-write rev check (`revToMatch`): a stale-rev write fails
* against a populated new target and self-heals on the next sync.
*/
private get _targetScopedMaps(): Map<string, unknown>[] {
return [
this._expectedSyncVersions,
this._pendingExpectedSyncVersions,
this._lastSeenVectorClocks,
this._pendingVectorClocks,
this._localSeqCounters,
// Included deliberately: without it a re-used provider key after a target
// switch could suppress a genuine corruption notice for the NEW target
// based on the OLD target's corrupt rev.
this._lastRecoveredCorruptRev,
// SPAP-10: dropping the last-seen rev prevents a stale value driving a
// false "nothing new" short-circuit against a different target.
this._lastSeenRevs,
this._pendingRevs,
this._syncCycleCache,
this._splitOpsCache,
] as Map<string, unknown>[];
}
/**
* Clears every target-scoped in-memory entry for one provider key. Shared by
* `deleteAllData` and per-key resets. Callers persist afterwards.
*/
private _resetTargetState(providerKey: string): void {
for (const map of this._targetScopedMaps) {
map.delete(providerKey);
}
}
/**
* Invalidate all cached target-scoped state after a user-authoritative
* provider/target/configuration change. The adapter is keyed only by provider
* id, so a provider switch, an account switch behind the same provider id, or
* an identity-affecting configuration save would otherwise reuse the previous
* target's sync version, rev, vector clock, and within-cycle caches reading
* or writing one target's data against another. Clearing every key forces the
* next sync to discover and full-read the current target from zero. Bumps the
* target generation so a later in-flight guard can detect the transition.
*
* CANONICAL WARNING only call this when the target ACTUALLY moved
* (`providerConfigChanged$`'s `isTargetChanged`, via `isSyncTargetChanged`),
* never on every config save. This drops `_localSeqCounters`, and a cursor back
* at 0 makes the next download return a `snapshotState` (`isForceFromZero`);
* for a client holding unsynced ops that classifies CONCURRENT and, with
* `AUTO_MERGE_CONCURRENT_SNAPSHOT` false, dead-ends in a binary conflict dialog
* whose either answer discards data. (The same hazard is documented from the
* other direction at the `latestSeq` computation in `_downloadOps`.) A real
* target move has no cursor worth keeping, so the reset is correct there and
* only there.
*
* Not wired to machine-only writes for an UNCHANGED account: OAuth token
* refresh goes through the provider credential store directly (never
* `setProviderConfig`), and content-only saves encryption key rotation, the
* `isEncryptionEnabled` backfill are filtered out by `isSyncTargetChanged`.
*/
invalidateAllTargets(): void {
this._loadPersistedState();
this._targetGeneration++;
for (const map of this._targetScopedMaps) {
map.clear();
}
this._persistState();
OpLog.normal(
'FileBasedSyncAdapter: target state invalidated after configuration change',
);
}
/**
* Wraps a provider so every remote write (`uploadFile`, `removeFile`) first
* asserts the target generation has not moved since `capturedGeneration`.
* A config/account/folder change mid-upload bumps the generation (via
* `invalidateAllTargets()`) and the same provider object then reads the new
* target's config, so an in-flight write of the previous target's merged data
* would land on the new target. The guard aborts before that write with
* `FileSyncTargetChangedError`; the next sync re-reads the current target.
*
* Reads (`downloadFile`, `getFileRev`) pass through reading the wrong target
* cannot corrupt it, and the subsequent write is what the guard blocks. Writes
* are checked individually rather than once per operation: this narrows (does
* not eliminate) the checkwrite race, and a mid-atomic-pair abort strands the
* write on the *previous* target exactly as a crash would, which the read path
* already tolerates. Capture the generation once at the operation boundary and
* thread this wrapper to every write site. (Task 2.)
*/
private _withTargetGuard(
rawProvider: FileSyncProvider<SyncProviderId>,
capturedGeneration: number,
): GuardedFileSyncProvider {
const assertUnchanged = (): void => {
if (this._targetGeneration !== capturedGeneration) {
throw new FileSyncTargetChangedError(capturedGeneration, this._targetGeneration);
}
};
// The Proxy is structurally a FileSyncProvider; the brand is a phantom type
// with no runtime property, so cast through unknown.
return new Proxy(rawProvider, {
get: (target, prop, receiver) => {
if (prop === 'uploadFile' || prop === 'removeFile') {
const writeFn = Reflect.get(target, prop, receiver) as (
...args: unknown[]
) => Promise<unknown>;
// Async wrapper so a guard rejection is a promise rejection, matching
// the real (Promise-returning) methods rather than a synchronous throw.
return async (...args: unknown[]): Promise<unknown> => {
assertUnchanged();
return writeFn.apply(target, args);
};
}
const value = Reflect.get(target, prop, receiver);
return typeof value === 'function' ? value.bind(target) : value;
},
}) as unknown as GuardedFileSyncProvider;
}
/**
* Aborts an in-flight DOWNLOAD before it commits a remote baseline if the
* target changed since the download started. Writes are already covered by
* `_withTargetGuard`, but a download READS target A, and if the target then
* switches (config/account/folder), staging A's baseline (sync version,
* vector clock, rev) and letting the caller advance the seq cursor under the
* shared provider id is silent data loss on the NEXT sync. The mechanism is
* suppressed gap detection, not skipped ops: both download paths return every
* op in the file and let the caller's `appliedOpIds` dedup filter, but a
* stale-HIGH `sinceSeq` makes `partialTrimGap` (`oldestOpSyncVersion >
* sinceSeq + 1`) false while a cleared `_expectedSyncVersions` makes
* `versionWasReset` false so `needsGapDetection` stays false and the new
* target's SNAPSHOT is never loaded. Any history the new target compacted below
* that snapshot is then silently missing. Dropping the target-scoped state and
* aborting forces the next sync to re-read the current target from zero;
* `SyncWrapperService` maps the error to a silent self-heal.
*
* This closes the dominant switch-during-download window. A switch after a
* successful download during op apply, or between an upload's write and its
* own `setLastServerSeq` cursor commit is a narrower residual that only a
* per-cycle session-generation captured in the orchestrator could fully close;
* within the current per-operation model the write guard plus this download
* abort cover the realistic exposure. (Task 2.)
*/
private _abortDownloadIfTargetChanged(
capturedGeneration: number,
providerKey: string,
): void {
if (this._targetGeneration !== capturedGeneration) {
this._resetTargetState(providerKey);
this._persistState();
throw new FileSyncTargetChangedError(capturedGeneration, this._targetGeneration);
}
}
/**
* Creates an OperationSyncCapable adapter for a file-based provider.
*
@ -455,7 +651,7 @@ export class FileBasedSyncAdapterService {
* Gets the current sync state from cache or by downloading.
*/
private async _getCurrentSyncState(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
providerKey: string,
@ -592,7 +788,7 @@ export class FileBasedSyncAdapterService {
* snapshot whose state never saw those ops.
*/
private async _uploadWithMismatchFallback(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
newData: FileBasedSyncData,
@ -670,7 +866,7 @@ export class FileBasedSyncAdapterService {
}
private async _uploadOps(
provider: FileSyncProvider<SyncProviderId>,
rawProvider: FileSyncProvider<SyncProviderId>,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
ops: SyncOperation[],
@ -678,6 +874,12 @@ export class FileBasedSyncAdapterService {
lastKnownServerSeq?: number,
localStateSnapshot?: unknown,
): Promise<OpUploadResponse> {
// Capture the target generation at the operation boundary and thread a
// write-guarded provider through the whole upload (single-file, split,
// REPAIR, and backup paths all receive it), so a mid-operation target switch
// aborts before any write instead of committing this target's merged data to
// the next one. (Task 2.)
const provider = this._withTargetGuard(rawProvider, this._targetGeneration);
const providerKey = this._getProviderKey(provider);
// SPAP-11: split-file ("Surgical sync") path is fully separate and only
@ -790,13 +992,21 @@ export class FileBasedSyncAdapterService {
// ═══════════════════════════════════════════════════════════════════════════
private async _downloadOps(
provider: FileSyncProvider<SyncProviderId>,
rawProvider: FileSyncProvider<SyncProviderId>,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
sinceSeq: number,
excludeClient?: string,
limit: number = 500,
): Promise<FileSnapshotOpDownloadResponse> {
// Download is read-only for the caller, but the split-format path resumes a
// crashed split migration by WRITING remote files (state/tombstone/marker)
// via _resumePendingSplitMigration. Guard those writes with the same
// generation captured at the download boundary, so a mid-download target
// switch aborts the resume before it lands the old target's migration on the
// new one. Reads (downloadFile/getFileRev) pass through unaffected. (Task 2.)
const capturedGeneration = this._targetGeneration;
const provider = this._withTargetGuard(rawProvider, capturedGeneration);
const providerKey = this._getProviderKey(provider);
// SPAP-11: split-file ("Surgical sync") download path (opt-in). When OFF
@ -810,6 +1020,7 @@ export class FileBasedSyncAdapterService {
excludeClient,
limit,
providerKey,
capturedGeneration,
);
}
@ -1038,6 +1249,12 @@ export class FileBasedSyncAdapterService {
);
}
// Abort before committing a baseline read from a target that switched
// mid-download: staging its sync-version/clock/rev and letting the caller
// advance the seq cursor under the shared provider id could make the next
// sync skip the NEW target's ops from a stale cursor. (Task 2.)
this._abortDownloadIfTargetChanged(capturedGeneration, providerKey);
// Stage the remote baseline until the caller confirms that the downloaded
// snapshot/ops were durably applied. A cancelled conflict dialog must leave
// the committed baseline intact so a later reset still triggers a gap.
@ -1146,7 +1363,7 @@ export class FileBasedSyncAdapterService {
// ═══════════════════════════════════════════════════════════════════════════
private async _uploadSnapshot(
provider: FileSyncProvider<SyncProviderId>,
rawProvider: FileSyncProvider<SyncProviderId>,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
state: unknown,
@ -1156,6 +1373,12 @@ export class FileBasedSyncAdapterService {
schemaVersion: number,
snapshotOpType?: RestorePointType,
): Promise<SnapshotUploadResponse> {
// Same in-flight target guard as _uploadOps: this is the second remote-write
// entry point (initial/recovery/migration + REPAIR snapshot writes, incl.
// _conditionalUploadRepairSnapshot and backups). A mid-operation target
// switch aborts before any write instead of committing this target's
// snapshot to the next one. (Task 2.)
const provider = this._withTargetGuard(rawProvider, this._targetGeneration);
const providerKey = this._getProviderKey(provider);
OpLog.normal(`FileBasedSyncAdapter: Uploading snapshot (reason=${reason})`);
@ -1322,18 +1545,10 @@ export class FileBasedSyncAdapterService {
}
}
// Reset local state
this._expectedSyncVersions.delete(providerKey);
this._localSeqCounters.delete(providerKey);
// SPAP-10: drop the last-seen rev so a stale value can't drive a false
// "nothing new" short-circuit after the remote file has been deleted.
this._lastSeenRevs.delete(providerKey);
this._pendingRevs.delete(providerKey);
this._lastSeenVectorClocks.delete(providerKey);
this._pendingExpectedSyncVersions.delete(providerKey);
this._pendingVectorClocks.delete(providerKey);
this._clearCachedSyncData(providerKey);
this._clearCachedOpsData(providerKey);
// Reset all target-scoped local state (incl. last-seen rev, so a stale
// value can't drive a false "nothing new" short-circuit after the remote
// file has been deleted).
this._resetTargetState(providerKey);
this._persistState();
return { success: true };
@ -1404,7 +1619,7 @@ export class FileBasedSyncAdapterService {
/** Downloads + decodes `sync-ops.json` and validates its version. */
private async _downloadOpsFile(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
): Promise<{ data: FileBasedOpsFile; rev: string }> {
@ -1436,7 +1651,7 @@ export class FileBasedSyncAdapterService {
* the immutable snapshot referenced by an ops file's `snapshotRef.file`.
*/
private async _downloadStateFile(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
path: string = FILE_BASED_SYNC_CONSTANTS.STATE_FILE,
@ -1470,7 +1685,7 @@ export class FileBasedSyncAdapterService {
* pre-#9040 clients that don't read `snapshotRef.file`.
*/
private async _writeStateFile(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
data: FileBasedStateFile,
@ -1521,7 +1736,7 @@ export class FileBasedSyncAdapterService {
* capability-gated).
*/
private async _removeGenStateFile(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
file: string,
): Promise<void> {
try {
@ -1533,7 +1748,7 @@ export class FileBasedSyncAdapterService {
/** Copies the current `sync-state.json` to its `.bak` (non-fatal). */
private async _backupStateFile(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
): Promise<void> {
@ -1557,7 +1772,7 @@ export class FileBasedSyncAdapterService {
/** Recovers the snapshot from `sync-state.json.bak` (null if unusable). */
private async _recoverStateFromBackup(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
): Promise<FileBasedStateFile | null> {
@ -1596,7 +1811,7 @@ export class FileBasedSyncAdapterService {
* Returns null when no snapshot validates the ref (caller signals a gap).
*/
private async _loadValidatedSnapshot(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
opsFile: FileBasedOpsFile,
@ -1671,7 +1886,7 @@ export class FileBasedSyncAdapterService {
* inline comment.
*/
private async _writeTombstoneAndNeutralizeBak(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
expectedLegacyRev?: string,
@ -1714,7 +1929,7 @@ export class FileBasedSyncAdapterService {
}
private async _writePendingSplitMigration(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
legacy: FileBasedSyncData,
@ -1775,7 +1990,7 @@ export class FileBasedSyncAdapterService {
}
private async _finalizeSplitMigrationMarker(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
pending: FileBasedOpsFile,
@ -1809,7 +2024,7 @@ export class FileBasedSyncAdapterService {
* used to build it or fails and causes the newer v2 payload to be re-imported.
*/
private async _resumePendingSplitMigration(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
initialPending: FileBasedOpsFile,
@ -1925,7 +2140,7 @@ export class FileBasedSyncAdapterService {
* rev), or null for a truly fresh folder.
*/
private async _maybeMigrateLegacyToSplit(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
clientId: string,
@ -1978,7 +2193,7 @@ export class FileBasedSyncAdapterService {
* to classify transient vs genuine concurrency and never force-overwrites.
*/
private async _uploadOpsFileWithMismatchFallback(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
newOpsFile: FileBasedOpsFile,
@ -2038,7 +2253,7 @@ export class FileBasedSyncAdapterService {
* then the trimmed `sync-ops.json` referencing it via snapshotRef.
*/
private async _uploadOpsSplit(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
ops: SyncOperation[],
@ -2268,13 +2483,14 @@ export class FileBasedSyncAdapterService {
* validates it against the ops file's snapshotRef (mismatch gap).
*/
private async _downloadOpsSplit(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
sinceSeq: number,
excludeClient: string | undefined,
limit: number,
providerKey: string,
capturedGeneration: number,
): Promise<FileSnapshotOpDownloadResponse> {
// SPAP-10 rev pre-check, extended to the ops file. Gated on `sinceSeq > 0`
// (review follow-up): a forceFromSeq0 download (sinceSeq === 0) re-pulls the
@ -2394,6 +2610,10 @@ export class FileBasedSyncAdapterService {
opsFile.oldestOpSyncVersion > sinceSeq + 1;
let needsGapDetection = versionWasReset || snapshotReplacement || partialTrimGap;
// See _downloadOps: abort before committing a baseline read from a target
// that switched mid-download. (Task 2.)
this._abortDownloadIfTargetChanged(capturedGeneration, providerKey);
this._pendingExpectedSyncVersions.set(providerKey, opsFile.syncVersion);
this._pendingVectorClocks.set(providerKey, opsFile.vectorClock);
this._stageOrDropDownloadedRev(providerKey, opsRev, recoveredFromBackup);
@ -2467,7 +2687,7 @@ export class FileBasedSyncAdapterService {
* empty when the folder is truly fresh (or only a tombstone remains).
*/
private async _tryLegacyReadOnlyDownload(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
sinceSeq: number,
@ -2522,7 +2742,7 @@ export class FileBasedSyncAdapterService {
* `sync-data.json` so OFF clients don't diverge.
*/
private async _uploadSnapshotSplit(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
clientId: string,
@ -2648,7 +2868,7 @@ export class FileBasedSyncAdapterService {
* (conditional PUT) is unaffected.
*/
private async _writeBakFile<T>(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
bakPath: string,
@ -2685,7 +2905,7 @@ export class FileBasedSyncAdapterService {
* caller then surfaces its ORIGINAL corruption error.
*/
private async _readBakFile<T extends { version: number }>(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
bakPath: string,
@ -2739,7 +2959,7 @@ export class FileBasedSyncAdapterService {
* the writes leaves a valid old primary + new .bak nothing stale to recover.)
*/
private async _forceUploadWithBakFirst(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
primaryPath: string,
bakPath: string,
encoded: string,
@ -2760,7 +2980,7 @@ export class FileBasedSyncAdapterService {
* absent" the provider rejects the write if another client created it.
*/
private async _conditionalUploadRepairSnapshot(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
primaryPath: string,
bakPath: string,
encoded: string,
@ -2886,7 +3106,7 @@ export class FileBasedSyncAdapterService {
* @returns The sync data and its revision (ETag) for conditional upload
*/
private async _downloadSyncFile(
provider: FileSyncProvider<SyncProviderId>,
provider: GuardedFileSyncProvider,
cfg: EncryptAndCompressCfg,
encryptKey: string | undefined,
): Promise<{ data: FileBasedSyncData; rev: string }> {

View file

@ -0,0 +1,121 @@
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';
import { provideMockStore } from '@ngrx/store/testing';
import {
SyncProviderManager,
notifyFileProviderTargetChanged,
} from './provider-manager.service';
import { DataInitStateService } from '../../core/data-init/data-init-state.service';
import { SnackService } from '../../core/snack/snack.service';
import { SyncProviderId } from './provider.const';
import { SyncProviderBase } from './provider.interface';
/**
* Task 2 (sync-simplification plan). providerConfigChanged$ fires on every save
* and carries `isTargetChanged`: true only when the save moved the TARGET. That
* flag drives invalidateAllTargets(), which wipes the seq cursor so a false
* positive sends the next sync down the sinceSeq===0 snapshot-bootstrap path.
* The picker / Android setupSaf / OneDrive pre-auth write bypass
* setProviderConfig() entirely and assert a target change directly.
*/
describe('SyncProviderManager target-change notification', () => {
let service: SyncProviderManager;
let configSpy: jasmine.Spy;
const webdavCfg = {
baseUrl: 'https://a.example/dav',
userName: 'me',
password: 'pw',
encryptKey: 'key-1',
isEncryptionEnabled: true,
};
/** Stubs getProviderById so setProviderConfig can run without loading providers. */
const stubProvider = (loadedCfg: unknown): jasmine.SpyObj<SyncProviderBase<never>> => {
const provider = {
id: SyncProviderId.WebDAV,
setPrivateCfg: jasmine.createSpy('setPrivateCfg').and.resolveTo(undefined),
privateCfg: { load: jasmine.createSpy('load').and.resolveTo(loadedCfg) },
} as unknown as jasmine.SpyObj<SyncProviderBase<never>>;
spyOn(service, 'getProviderById').and.resolveTo(
provider as unknown as SyncProviderBase<SyncProviderId>,
);
return provider;
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
SyncProviderManager,
provideMockStore({}),
{
provide: DataInitStateService,
// Never emits, so the constructor's sync-config subscription stays
// inert and we don't need to mock provider loading.
useValue: { isAllDataLoadedInitially$: new Subject<boolean>() },
},
{
provide: SnackService,
useValue: jasmine.createSpyObj('SnackService', ['open']),
},
],
});
service = TestBed.inject(SyncProviderManager);
configSpy = jasmine.createSpy('providerConfigChanged');
service.providerConfigChanged$.subscribe(configSpy);
});
describe('notifyProviderTargetChanged() (bypass ingresses)', () => {
it('emits isTargetChanged — the caller asserts the move directly', () => {
service.notifyProviderTargetChanged();
expect(configSpy).toHaveBeenCalledOnceWith({ isTargetChanged: true });
});
it('routes the module-level notifyFileProviderTargetChanged() to the registered instance', () => {
// Injecting the service self-registered it as the module singleton.
notifyFileProviderTargetChanged();
expect(configSpy).toHaveBeenCalledOnceWith({ isTargetChanged: true });
});
});
describe('setProviderConfig()', () => {
// The per-field identity matrix lives in sync-target-identity.util.spec.ts.
// These two only pin that the flag is wired to the diff, in both directions.
it('reports isTargetChanged:false when nothing moved (e.g. Save with no edits)', async () => {
// The sync-settings dialog saves with isForce=true, bypassing the
// equality dedup, so this rewrite happens on every Save — including one
// that only touched a global setting like the sync interval.
stubProvider(webdavCfg);
await service.setProviderConfig(SyncProviderId.WebDAV, { ...webdavCfg } as never);
expect(configSpy).toHaveBeenCalledOnceWith({ isTargetChanged: false });
});
it('reports isTargetChanged:true when the folder moves', async () => {
stubProvider(webdavCfg);
await service.setProviderConfig(SyncProviderId.WebDAV, {
...webdavCfg,
syncFolderPath: '/elsewhere',
} as never);
expect(configSpy).toHaveBeenCalledOnceWith({ isTargetChanged: true });
});
it('reads the previous config BEFORE overwriting it', async () => {
// The diff is only meaningful against the pre-write value.
const provider = stubProvider(webdavCfg);
await service.setProviderConfig(SyncProviderId.WebDAV, {
...webdavCfg,
userName: 'someone-else',
} as never);
expect(provider.privateCfg.load).toHaveBeenCalledBefore(provider.setPrivateCfg);
});
});
});

View file

@ -22,6 +22,7 @@ import {
CurrentProviderPrivateCfg,
} from '../core/types/sync.types';
import { loadSyncProviders } from './sync-providers.factory';
import { isSyncTargetChanged } from './sync-target-identity.util';
/**
* Sync status change payload type
@ -32,6 +33,37 @@ export type SyncStatusChangePayload =
| 'IN_SYNC'
| 'SYNCING';
/** Payload of `providerConfigChanged$`. See that observable for the semantics. */
export interface ProviderConfigChange {
/** True only when the write moved the sync target (see `isSyncTargetChanged`). */
isTargetChanged: boolean;
}
// Module-level reference so static sync-form handlers can signal a target
// change without an injector (mirrors the encryption-dialog-opener pattern).
let providerManagerInstance: SyncProviderManager | null = null;
const setProviderManagerInstance = (instance: SyncProviderManager): void => {
providerManagerInstance = instance;
};
/**
* Signal that the active file-provider target changed through an ingress that
* bypasses `setProviderConfig()` the Electron LocalFile folder picker (which
* persists the folder main-side, post-#8228) and Android `setupSaf()` (which
* writes `safFolderUri` straight to the credential store). Both mutate the
* target without firing `providerTargetChanged$`, so the file adapter would keep
* the previous folder's revs/clocks/caches keyed by the (unchanged) `LocalFile`
* provider id. Both ingresses are unambiguous target moves (the user picked a
* different folder), so this asserts a target change directly rather than
* inferring one from a config diff. It no-ops if the manager was never
* instantiated nothing is cached to leak.
* (Task 2, docs/plans/2026-07-13-sync-simplification-plan.md.)
*/
export const notifyFileProviderTargetChanged = (): void => {
providerManagerInstance?.notifyProviderTargetChanged();
};
/**
* Service for managing sync providers.
*
@ -74,7 +106,7 @@ export class SyncProviderManager {
new BehaviorSubject<CurrentProviderPrivateCfg | null>(null);
// Emits whenever provider config is updated via setProviderConfig()
private _providerConfigChanged$ = new Subject<void>();
private _providerConfigChanged$ = new Subject<ProviderConfigChange>();
private _hasShownLocalFileReselectSnack = false;
/**
@ -115,10 +147,21 @@ export class SyncProviderManager {
this._currentProviderPrivateCfg$.pipe(shareReplay(1));
/**
* Emits whenever provider config is updated via setProviderConfig().
* Used by WrappedProviderService to auto-invalidate its adapter cache.
* Emits on EVERY provider-config write, carrying whether that write moved the
* sync TARGET an account switch behind the same provider id, or a folder/URL
* change as opposed to a content-only edit. Both ride ONE emission so a
* caller cannot raise a move without the cache drop, which would leave a stale
* encryption key cached against fresh target state.
* See `isSyncTargetChanged` and `FileBasedSyncAdapterService.invalidateAllTargets`.
*
* `isTargetChanged` is scoped to ONE provider's config and says nothing about
* which provider is ACTIVE the two axes have separate detectors. A provider
* SWITCH is handled by `SyncWrapperService`'s `getLastSyncedProviderId()` check
* `forceFromSeq0`, so switching to an already-configured provider correctly
* emits `isTargetChanged: false`: its provider-id-keyed state still describes
* its own unchanged remote.
*/
public readonly providerConfigChanged$: Observable<void> =
public readonly providerConfigChanged$: Observable<ProviderConfigChange> =
this._providerConfigChanged$.asObservable();
/**
@ -130,6 +173,10 @@ export class SyncProviderManager {
);
constructor() {
// Self-register so the module-level notifyFileProviderTargetChanged() can
// reach this singleton from static form config handlers.
setProviderManagerInstance(this);
// Listen to sync config changes and update active provider
this._syncConfig$.subscribe((cfg) => {
try {
@ -236,13 +283,22 @@ export class SyncProviderManager {
if (!provider) {
throw new Error(`Provider not found: ${providerId}`);
}
// Read the previous config BEFORE the write so an identity-affecting change
// (account/folder/URL) can be told apart from a content-only one. Callers
// save the whole privateCfg on every settings-dialog Save — including saves
// that only touched a global setting — so "config was written" is far weaker
// than "the target moved". See providerTargetChanged$.
const prevCfg = await provider.privateCfg.load();
// Use setPrivateCfg() instead of privateCfg.setComplete() to ensure
// provider-specific caches (like lastServerSeq key) are invalidated.
// This is critical for server migration detection when switching users.
await provider.setPrivateCfg(config);
// Notify subscribers (e.g., WrappedProviderService) that config changed
this._providerConfigChanged$.next();
this._providerConfigChanged$.next({
isTargetChanged: isSyncTargetChanged(prevCfg, config),
});
// If this is the active provider, update the current config observable
if (this._activeProvider?.id === providerId) {
@ -257,6 +313,21 @@ export class SyncProviderManager {
}
}
/**
* Asserts a target change for a write that bypassed `setProviderConfig()`, so
* no config diff is available to infer it from. Three such ingresses exist: the
* Electron LocalFile folder picker, Android `setupSaf()`, and the OneDrive
* pre-auth cfg write in `dialog-sync-cfg.component`. Callers that fire on every
* save (OneDrive) MUST gate this on `isSyncTargetChanged` an unconditional
* notify reintroduces the cursor wipe this signal exists to avoid.
*
* Kept minimal on purpose: it does not reload provider config (the caller
* already persisted the new target). See `notifyFileProviderTargetChanged()`.
*/
notifyProviderTargetChanged(): void {
this._providerConfigChanged$.next({ isTargetChanged: true });
}
/**
* Clears authentication credentials for a provider while preserving non-auth config.
* Updates readiness state and config observable after clearing.

View file

@ -0,0 +1,94 @@
import { isSyncTargetChanged } from './sync-target-identity.util';
describe('isSyncTargetChanged', () => {
const webdavCfg = {
baseUrl: 'https://a.example/dav',
userName: 'me',
password: 'pw',
syncFolderPath: '/sp',
encryptKey: 'key-1',
isEncryptionEnabled: true,
};
describe('content-only edits (must NOT invalidate)', () => {
it('ignores key order', () => {
// Subsumes the identical-config case; a persist/load round-trip is not
// guaranteed to preserve key order.
expect(
isSyncTargetChanged(
{ baseUrl: 'https://a.example/dav', userName: 'me' },
{ userName: 'me', baseUrl: 'https://a.example/dav' },
),
).toBe(false);
});
it('treats an absent field and an explicit undefined as equal', () => {
expect(
isSyncTargetChanged(
{ baseUrl: 'u' },
{ baseUrl: 'u', syncFolderPath: undefined },
),
).toBe(false);
});
it("treats PROVIDER_FIELD_DEFAULTS' '' sentinel as equal to absent", () => {
// The first save after a new default field lands merges `{field: ''}` onto
// a config that lacks it; that is not a target move.
expect(isSyncTargetChanged({ baseUrl: 'u' }, { baseUrl: 'u', password: '' })).toBe(
false,
);
});
it('ignores an encryption key rotation', () => {
expect(isSyncTargetChanged(webdavCfg, { ...webdavCfg, encryptKey: 'key-2' })).toBe(
false,
);
});
it('ignores the GHSA-9544 isEncryptionEnabled backfill', () => {
// WrappedProviderService._backfillEncryptionIntent writes exactly this
// (undefined -> true) mid-sync. Firing a target change for it would wipe
// the seq cursor and abort the in-flight sync it was spawned from.
const preFix = { ...webdavCfg, isEncryptionEnabled: undefined };
expect(isSyncTargetChanged(preFix, { ...preFix, isEncryptionEnabled: true })).toBe(
false,
);
});
});
describe('target moves (MUST invalidate)', () => {
it('detects a folder change', () => {
expect(
isSyncTargetChanged(webdavCfg, { ...webdavCfg, syncFolderPath: '/other' }),
).toBe(true);
});
it('still detects clearing a configured folder', () => {
// The '' <-> absent collapse must not mask a real move: '/foo' -> root.
expect(
isSyncTargetChanged({ baseUrl: 'u', syncFolderPath: '/foo' }, { baseUrl: 'u' }),
).toBe(true);
});
it('detects an OAuth account switch (refresh token replaced)', () => {
// Dropbox has no account field at all — the tokens ARE the identity, which
// is why they must stay outside CONTENT_ONLY_CFG_FIELDS.
const dropbox = { accessToken: 'a1', refreshToken: 'r1' };
expect(isSyncTargetChanged(dropbox, { ...dropbox, refreshToken: 'r2' })).toBe(true);
});
it('treats first-time setup (no previous config) as a change', () => {
expect(isSyncTargetChanged(null, webdavCfg)).toBe(true);
});
});
it('fails safe: an unrecognised field counts as identity-affecting', () => {
// Only encryptKey/isEncryptionEnabled are provably content-only. Anything a
// future provider adds must invalidate rather than silently reuse a cursor —
// a false negative is silent cross-target corruption, which is worse than
// the (also bad) spurious full re-read a false positive costs.
expect(
isSyncTargetChanged({ baseUrl: 'u' }, { baseUrl: 'u', someNewTargetField: 'x' }),
).toBe(true);
});
});

View file

@ -0,0 +1,57 @@
/**
* Fields describing HOW content is encrypted, not WHICH remote is addressed.
*
* Every other field counts as identity-affecting, so an unrecognised or newly
* added one errs toward invalidating rather than silently reusing one target's
* cursor against another. Only add a field here once it provably cannot change
* which remote is addressed.
*/
const CONTENT_ONLY_CFG_FIELDS: ReadonlySet<string> = new Set([
'encryptKey',
'isEncryptionEnabled',
]);
/**
* `PROVIDER_FIELD_DEFAULTS` (sync-config.service) is re-merged on every save, so
* a config predating a default gets that key seeded on its next save which
* without this would read as a target move. Attested: `loginName: ''` was added
* to Nextcloud's defaults after configs existed. Cannot mask a real move
* clearing a folder still compares `'/foo'` absent.
*
* Deliberately incomplete: OneDrive's defaults are the only non-`''` ones
* (`tenantId: 'common'`, `syncFolderPath: 'Super Productivity'`, ), so such a
* config still reports a spurious move on its first save one-time and
* self-correcting. Covering it would drag the imex/sync defaults table into
* op-log for less than it costs.
*/
const isUnset = (value: unknown): boolean => value === undefined || value === '';
/**
* Sorted because key order need not survive a persist/load round-trip; flat
* because every provider privateCfg is `string | boolean | number` throughout.
*/
const toTargetIdentity = (cfg: Record<string, unknown>): string =>
Object.entries(cfg)
.filter(([k, v]) => !CONTENT_ONLY_CFG_FIELDS.has(k) && !isUnset(v))
.sort(([a], [b]) => (a < b ? -1 : 1))
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
.join('&');
/**
* Whether two configs address a DIFFERENT remote (account switch behind the same
* provider id, folder/URL change) rather than a content-only edit. Keeps
* per-target state alive across routine saves see
* `FileBasedSyncAdapterService.invalidateAllTargets` for why firing on a
* content-only save loses data. No previous config (first-time setup) counts as
* a change; there is no state to keep.
*
* Do NOT add a `prevCfg === nextCfg` fast path. It cannot speed up any real
* input, and the one case it changes the answer it gets wrong:
* `SyncCredentialStore.load()` returns the live cached object, so a caller that
* mutates it in place and passes it back would be told "no change" a silent
* false negative. Callers must pass a new object (all do today).
*/
export const isSyncTargetChanged = (prevCfg: unknown, nextCfg: object): boolean =>
!prevCfg ||
toTargetIdentity(prevCfg as Record<string, unknown>) !==
toTargetIdentity(nextCfg as Record<string, unknown>);

View file

@ -14,7 +14,7 @@ describe('WrappedProviderService', () => {
let service: WrappedProviderService;
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
let mockFileBasedAdapter: jasmine.SpyObj<FileBasedSyncAdapterService>;
let providerConfigChanged$: Subject<void>;
let providerConfigChanged$: Subject<{ isTargetChanged: boolean }>;
const createMockBaseProvider = (
id: SyncProviderId,
@ -66,7 +66,7 @@ describe('WrappedProviderService', () => {
});
beforeEach(() => {
providerConfigChanged$ = new Subject<void>();
providerConfigChanged$ = new Subject<{ isTargetChanged: boolean }>();
mockProviderManager = jasmine.createSpyObj(
'SyncProviderManager',
@ -81,6 +81,7 @@ describe('WrappedProviderService', () => {
mockFileBasedAdapter = jasmine.createSpyObj('FileBasedSyncAdapterService', [
'createAdapter',
'invalidateAllTargets',
]);
TestBed.configureTestingModule({
@ -306,12 +307,43 @@ describe('WrappedProviderService', () => {
expect(result1).toBe(mockAdapter1);
// Simulate config change
providerConfigChanged$.next();
providerConfigChanged$.next({ isTargetChanged: false });
// Next call should create a new adapter (cache was auto-cleared)
const result2 = await service.getOperationSyncCapable(dropboxProvider);
expect(result2).toBe(mockAdapter2);
expect(mockFileBasedAdapter.createAdapter).toHaveBeenCalledTimes(2);
});
it('should invalidate file-adapter target state when the target moved', () => {
// A real target move (account switch behind the same provider id, folder
// change); the file adapter is keyed only by provider id, so its per-target
// state must be dropped or it leaks across targets (Task 2).
expect(mockFileBasedAdapter.invalidateAllTargets).not.toHaveBeenCalled();
providerConfigChanged$.next({ isTargetChanged: true });
expect(mockFileBasedAdapter.invalidateAllTargets).toHaveBeenCalledTimes(1);
});
it('should NOT invalidate file-adapter target state on a config-only change', () => {
// The cursor lives in the file adapter's target state. Wiping it on a save
// that did not move the target (sync interval, compression, an encryption
// key rotation, the isEncryptionEnabled backfill, or Save with nothing
// changed) sends the next sync down the sinceSeq===0 snapshot-bootstrap
// path, which dead-ends in a spurious conflict dialog for any client
// holding unsynced ops.
providerConfigChanged$.next({ isTargetChanged: false });
expect(mockFileBasedAdapter.invalidateAllTargets).not.toHaveBeenCalled();
});
it('should still drop the adapter cache on a config-only change', () => {
// The cache holds the resolved encryption key/intent, so it must be
// rebuilt even when the target stayed put.
providerConfigChanged$.next({ isTargetChanged: false });
expect(service['_cache'].size).toBe(0);
});
});
});

View file

@ -47,14 +47,27 @@ export class WrappedProviderService {
private _cache = new Map<string, OperationSyncCapable>();
constructor() {
// Auto-invalidate cache when provider config changes
this._providerManager.providerConfigChanged$
.pipe(takeUntilDestroyed(this._destroyRef))
.subscribe(() => {
.subscribe(({ isTargetChanged }) => {
// Always: the cached adapter closes over the resolved encryption
// key/intent, so any config edit must rebuild it.
this._cache.clear();
OpLog.normal(
'WrappedProviderService: Cache auto-invalidated due to config change',
);
// Only on a real target move: the file adapter is keyed by provider id
// alone, so its sync-version/rev/clock and within-cycle caches would be
// reused against the new target. Guarded rather than unconditional
// because this also wipes the seq cursor — see invalidateAllTargets().
// (Task 2, docs/plans/2026-07-13-sync-simplification-plan.md.)
if (isTargetChanged) {
this._fileBasedAdapter.invalidateAllTargets();
OpLog.normal(
'WrappedProviderService: File-adapter target state invalidated (target changed)',
);
}
});
}

View file

@ -0,0 +1,84 @@
import {
FileBasedSyncTestHarness,
HarnessClient,
} from '../helpers/file-based-sync-test-harness';
import { FileSnapshotOpDownloadResponse } from '../../../sync-providers/provider.interface';
/**
* Task 2 (#9063 + follow-up): what `invalidateAllTargets()` actually COSTS.
*
* The unit specs prove the gate (only a real target move invalidates). These
* prove why the gate matters, against a real `FileBasedSyncAdapterService`:
* invalidate -> cursor 0 -> downloadOps(0) -> isForceFromZero -> a full
* `snapshotState` instead of an incremental op list.
*
* A client holding unsynced ops then classifies that snapshot CONCURRENT and,
* with `AUTO_MERGE_CONCURRENT_SNAPSHOT` false, dead-ends in the binary conflict
* dialog whose either answer discards data (that half lives in
* OperationLogSyncService and has its own specs). So invalidating on a save that
* did not move the target is a data-loss hazard, not an "extra full read".
*/
describe('File-Based Sync Integration - target invalidation cost (Task 2)', () => {
let harness: FileBasedSyncTestHarness;
let clientA: HarnessClient;
let clientB: HarnessClient;
/** A→B: A uploads one op, B pulls from its own committed cursor. */
const uploadFromAAndPullToB = async (
title: string,
): Promise<FileSnapshotOpDownloadResponse> => {
const op = clientA.createOp('Task', `task-${title}`, 'CRT', 'ADD_TASK', { title });
await clientA.uploadOps([op]);
// Mirror production ordering: read the committed cursor, download from it,
// then commit the new cursor only after a successful "apply".
const sinceSeq = await clientB.adapter.getLastServerSeq();
const res = (await clientB.adapter.downloadOps(
sinceSeq,
clientB.clientId,
)) as FileSnapshotOpDownloadResponse;
await clientB.adapter.setLastServerSeq(res.latestSeq);
return res;
};
beforeEach(async () => {
harness = FileBasedSyncTestHarness.create({});
clientA = harness.createClient('client-a-target-inv');
clientB = harness.createClient('client-b-target-inv');
// Establish a real committed cursor on B by syncing once.
await uploadFromAAndPullToB('first');
expect(await clientB.adapter.getLastServerSeq()).toBeGreaterThan(0);
});
afterEach(() => {
harness.reset();
});
it('keeps the download incremental while the cursor survives', async () => {
// The control: no invalidation -> no snapshot bootstrap. Without this, the
// test below would pass even if EVERY download returned a snapshot.
const res = await uploadFromAAndPullToB('second');
expect(res.snapshotState).toBeUndefined();
expect(res.ops.length).toBeGreaterThan(0);
});
it('forces a full snapshot bootstrap once the cursor is invalidated', async () => {
const cursorBefore = await clientB.adapter.getLastServerSeq();
expect(cursorBefore).toBeGreaterThan(0);
// Exactly what a target move triggers via providerConfigChanged$.
clientB.adapterService.invalidateAllTargets();
// The cursor is gone — and it is PERSISTED gone, so a restart won't recover it.
expect(await clientB.adapter.getLastServerSeq()).toBe(0);
const res = await uploadFromAAndPullToB('third');
// sinceSeq === 0 => isForceFromZero => the whole remote state comes back.
// This is the payload that classifies CONCURRENT against unsynced local ops
// and surfaces the data-losing conflict dialog.
expect(res.snapshotState).toBeDefined();
});
});

View file

@ -28,6 +28,13 @@ export interface HarnessClient {
/** The operation sync adapter for this client */
adapter: OperationSyncCapable;
/**
* The adapter SERVICE backing `adapter` one per client, mirroring production
* (each app instance owns its own). Exposed so tests can drive service-level
* transitions such as `invalidateAllTargets()`.
*/
adapterService: FileBasedSyncAdapterService;
/** Test client for vector clock management */
testClient: TestClient;
@ -294,6 +301,7 @@ export class FileBasedSyncTestHarness {
const client: HarnessClient = {
clientId,
adapter,
adapterService,
testClient,
uploadOps: async (ops: SyncOperation[]): Promise<OpUploadResponse> => {

View file

@ -0,0 +1,130 @@
import { TestBed } from '@angular/core/testing';
import { Subject } from 'rxjs';
import { provideMockStore } from '@ngrx/store/testing';
import { SyncProviderManager } from '../../sync-providers/provider-manager.service';
import { WrappedProviderService } from '../../sync-providers/wrapped-provider.service';
import { FileBasedSyncAdapterService } from '../../sync-providers/file-based/file-based-sync-adapter.service';
import { ArchiveDbAdapter } from '../../../core/persistence/archive-db-adapter.service';
import { StateSnapshotService } from '../../backup/state-snapshot.service';
import { DataInitStateService } from '../../../core/data-init/data-init-state.service';
import { SnackService } from '../../../core/snack/snack.service';
import { SyncProviderId } from '../../sync-providers/provider.const';
import { SyncProviderBase } from '../../sync-providers/provider.interface';
/**
* Task 2 (#9063 + follow-up): the invalidation wiring, through the REAL injector.
*
* The other specs for this fix mock `SyncProviderManager` and assert against a
* hand-made Subject, which proves the handler logic but assumes the wiring. Here
* all three services are real and only `getProviderById` is stubbed, so the whole
* production path runs: setProviderConfig -> isSyncTargetChanged ->
* providerConfigChanged$ -> the real subscription -> invalidateAllTargets -> the
* persisted cursor. Catches what mocked specs structurally cannot: the observable
* never being subscribed, or the flag not reaching the adapter.
*/
describe('Target-invalidation wiring (real DI)', () => {
let providerManager: SyncProviderManager;
let adapterService: FileBasedSyncAdapterService;
const webdavCfg = {
baseUrl: 'https://a.example/dav',
userName: 'me',
password: 'pw',
syncFolderPath: '/sp',
encryptKey: 'key-1',
isEncryptionEnabled: true,
};
/** Reads the cursor the way createAdapter's closure does. */
const getCursor = (): number =>
adapterService['_localSeqCounters'].get(SyncProviderId.WebDAV) ?? 0;
const stubProvider = (storedCfg: Record<string, unknown>): void => {
let stored = { ...storedCfg };
spyOn(providerManager, 'getProviderById').and.resolveTo({
id: SyncProviderId.WebDAV,
isReady: async () => true,
setPrivateCfg: async (cfg: Record<string, unknown>) => {
stored = { ...cfg };
},
privateCfg: { load: async () => stored },
} as unknown as SyncProviderBase<SyncProviderId>);
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
SyncProviderManager,
WrappedProviderService,
FileBasedSyncAdapterService,
provideMockStore({}),
{
provide: DataInitStateService,
// Never emits, so the manager's sync-config subscription stays inert.
useValue: { isAllDataLoadedInitially$: new Subject<boolean>() },
},
{
provide: SnackService,
useValue: jasmine.createSpyObj('SnackService', ['open']),
},
{ provide: ArchiveDbAdapter, useValue: {} },
{ provide: StateSnapshotService, useValue: {} },
],
});
providerManager = TestBed.inject(SyncProviderManager);
adapterService = TestBed.inject(FileBasedSyncAdapterService);
// Constructing this is what registers the real subscription — exactly as the
// effects -> SyncWrapperService -> WrappedProviderService chain does at boot.
TestBed.inject(WrappedProviderService);
adapterService['_localSeqCounters'].set(SyncProviderId.WebDAV, 42);
});
afterEach(() => {
adapterService.invalidateAllTargets();
localStorage.removeItem('FILE_SYNC_VERSION_state');
});
it('keeps the seq cursor when a save does not move the target', async () => {
// The real Defect 1 trigger: the settings dialog saves with isForce=true, so
// this rewrite happens even when only a global setting (sync interval,
// compression) changed — or nothing at all.
stubProvider(webdavCfg);
await providerManager.setProviderConfig(SyncProviderId.WebDAV, {
...webdavCfg,
} as never);
expect(getCursor()).toBe(42);
});
it('keeps the seq cursor across an encryption-key rotation and the intent backfill', async () => {
stubProvider({ ...webdavCfg, isEncryptionEnabled: undefined });
await providerManager.setProviderConfig(SyncProviderId.WebDAV, {
...webdavCfg,
encryptKey: 'key-2',
isEncryptionEnabled: true,
} as never);
expect(getCursor()).toBe(42);
});
it('drops the seq cursor when the target actually moves', async () => {
stubProvider(webdavCfg);
await providerManager.setProviderConfig(SyncProviderId.WebDAV, {
...webdavCfg,
syncFolderPath: '/elsewhere',
} as never);
expect(getCursor()).toBe(0);
});
it('drops the seq cursor when a bypass ingress asserts a move (picker / SAF / OneDrive)', () => {
providerManager.notifyProviderTargetChanged();
expect(getCursor()).toBe(0);
});
});