fix(sync): handle initial provider setup safely

This commit is contained in:
Johannes Millan 2026-07-11 11:25:57 +02:00
parent bfdc63e0c6
commit eaa15fe575
9 changed files with 571 additions and 86 deletions

View file

@ -105,6 +105,6 @@ flowchart TD
**Notes:**
- The `Enter Password` and `Decrypt Error` dialogs correspond to `DecryptNoPasswordError` and `DecryptError` respectively — they are distinct components with different options.
- `IMPORT_CONFLICT` uses pending ops only, not already-synced store contents (`_hasMeaningfulPendingOps()`). Every pending op is protected except onboarding example-task creates. This includes GLOBAL_CONFIG changes before first sync, non-task entities, and MIGRATION/RECOVERY genesis batches. PASSWORD_CHANGED SYNC_IMPORTs without pending ops still fall through to silent acceptance — the data is identical, only the encryption changed. Including already-synced store contents would let an old client pick `USE_LOCAL` and force-upload stale pre-import state, rolling back the remote import for everyone.
- `IMPORT_CONFLICT` uses pending ops only, not already-synced store contents (`_hasMeaningfulPendingOps()`). Every pending op is protected except onboarding example-task creates and the never-synced `GLOBAL_CONFIG:sync` provider-setup write. Other config sections, non-task entities, and MIGRATION/RECOVERY full-state batches remain protected. PASSWORD_CHANGED SYNC_IMPORTs without meaningful pending ops still fall through to silent acceptance — the data is identical, only the encryption changed. Including already-synced store contents would let an old client pick `USE_LOCAL` and force-upload stale pre-import state, rolling back the remote import for everyone.
- LWW tie-breaking: on equal timestamps, remote wins (server-authoritative). `moveToArchive` operations always win regardless of timestamp.
- Re-download retry limit: max 3 resolution attempts per entity (`MAX_CONCURRENT_RESOLUTION_ATTEMPTS`); if exceeded, ops are permanently rejected.

View file

@ -165,9 +165,9 @@ Comprehensive spec of all scenarios that can occur during SuperSync synchronizat
**Expected:**
1. Download detects remote snapshot (file-based sync path)
2. Treat every pending op as meaningful except onboarding example-task creates. GLOBAL_CONFIG writes remain protected even before the first completed sync because the sync-section payload can also contain user preferences. This also protects non-task entities and recovered MIGRATION/RECOVERY genesis batches.
2. Treat every pending op as meaningful except onboarding example-task creates and, before the first completed sync only, the `GLOBAL_CONFIG:sync` setup write needed to configure the provider. Other config sections, non-task entities, and MIGRATION/RECOVERY full-state batches remain protected.
3. If meaningful → throw `LocalDataConflictError` → full conflict dialog
4. If only the explicitly discardable startup ops remain → proceed without dialog
4. If only the explicitly discardable startup ops remain → proceed without dialog and reject those ops locally so they cannot replay after the imported state
**Note:** This op-content check only applies to the file-based snapshot path. For SuperSync (incremental ops path), the fresh client check uses `_hasMeaningfulStoreData()` (store-based check) instead.
@ -198,7 +198,7 @@ Comprehensive spec of all scenarios that can occur during SuperSync synchronizat
**Expected:**
1. Download batch contains SYNC_IMPORT
2. Check pending local ops. Any pending work triggers the dialog except onboarding example-task creates. GLOBAL_CONFIG changes are protected even before the first completed sync because the sync-section payload can carry user preferences.
2. Check pending local ops. Any pending work triggers the dialog except onboarding example-task creates and the never-synced `GLOBAL_CONFIG:sync` provider-setup write. Other GLOBAL_CONFIG sections remain protected.
3. **Show conflict dialog BEFORE processing** with `scenario: 'INCOMING_IMPORT'` and `syncImportReason`
4. USE_LOCAL → `forceUploadLocalState()` (overrides remote with local data)
5. USE_REMOTE → `forceDownloadRemoteState()` (clears local ops, downloads from seq 0)
@ -254,12 +254,12 @@ Comprehensive spec of all scenarios that can occur during SuperSync synchronizat
1. Upload completes → server returns piggybacked ops containing SYNC_IMPORT
2. Check for SYNC_IMPORT in piggybacked ops BEFORE `processRemoteOps()`
3. If found AND `_hasMeaningfulPendingOps()` = true (all pending work except onboarding example-task creates):
3. If found AND `_hasMeaningfulPendingOps()` = true (all pending work except onboarding example-task creates and the never-synced `GLOBAL_CONFIG:sync` provider-setup write):
- **Show conflict dialog** with `scenario: 'INCOMING_IMPORT'` and `syncImportReason` from the piggybacked op
- USE_LOCAL → `forceUploadLocalState()` (overrides remote)
- USE_REMOTE → `forceDownloadRemoteState()` (clears local, downloads from seq 0)
- CANCEL → return with `cancelled: true`, callers skip post-upload logic
4. If no meaningful pending ops → `processRemoteOps()` applies silently (no dialog) regardless of whether the NgRx store already has user data — that data was already synced and the SYNC_IMPORT is the new authoritative state.
4. If no meaningful pending ops → `processRemoteOps()` applies silently (no dialog), then reject live discardable startup ops only after processing succeeds. Existing NgRx store data does not trigger a dialog because it was already synced and the SYNC_IMPORT is the new authoritative state.
**Mirrors the download path (D.1 / D.2):** the gate is unsynced pending changes, not store contents. Prompting on already-synced store data would let an old client roll back the remote import via USE_LOCAL.

View file

@ -12,7 +12,10 @@ import { ValidateStateService } from '../validation/validate-state.service';
import { SyncSessionValidationService } from './sync-session-validation.service';
import { RepairOperationService } from '../validation/repair-operation.service';
import { OperationLogUploadService } from './operation-log-upload.service';
import { OperationLogDownloadService } from './operation-log-download.service';
import {
DownloadResult,
OperationLogDownloadService,
} from './operation-log-download.service';
import { LockService } from './lock.service';
import { OperationLogCompactionService } from '../persistence/operation-log-compaction.service';
import { SyncImportFilterService } from './sync-import-filter.service';
@ -45,6 +48,7 @@ import { OperationSyncCapable } from '../sync-providers/provider.interface';
import { selectSyncConfig } from '../../features/config/store/global-config.reducer';
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
import { SyncProviderId } from '../sync-providers/provider.const';
import { stripLocalOnlySyncSettingsFromAppData } from '../../features/config/local-only-sync-settings.util';
describe('OperationLogSyncService', () => {
let service: OperationLogSyncService;
@ -64,6 +68,24 @@ describe('OperationLogSyncService', () => {
let operationApplierSpy: jasmine.SpyObj<OperationApplierService>;
let operationLogEffectsSpy: jasmine.SpyObj<OperationLogEffects>;
const createProviderSetupEntry = (): OperationLogEntry => ({
seq: 1,
op: {
id: 'sync-provider-setup',
clientId: 'client-A',
actionType: ActionType.GLOBAL_CONFIG_UPDATE_SECTION,
opType: OpType.Update,
entityType: 'GLOBAL_CONFIG',
entityId: 'sync',
payload: { sectionKey: 'sync' },
vectorClock: { clientA: 1 },
timestamp: Date.now(),
schemaVersion: 1,
},
appliedAt: Date.now(),
source: 'local',
});
beforeEach(() => {
snackServiceSpy = jasmine.createSpyObj('SnackService', [
'open',
@ -1669,10 +1691,8 @@ describe('OperationLogSyncService', () => {
source: 'local',
});
const fileSnapshotDownloadResult = {
const fileSnapshotDownloadResult: DownloadResult = {
newOps: [],
hasMore: false,
latestSeq: 0,
needsFullStateUpload: false,
success: true,
providerMode: 'fileSnapshotOps',
@ -1682,6 +1702,32 @@ describe('OperationLogSyncService', () => {
latestServerSeq: 1,
};
it('silently adopts a file snapshot and rejects the never-synced provider setup op', async () => {
const setupEntry = createProviderSetupEntry();
opLogStoreSpy.hasSyncedOps.and.resolveTo(false);
opLogStoreSpy.getUnsynced.and.resolveTo([setupEntry]);
const syncHydrationServiceSpy = TestBed.inject(
SyncHydrationService,
) as jasmine.SpyObj<SyncHydrationService>;
syncHydrationServiceSpy.hydrateFromRemoteSync.and.resolveTo();
downloadServiceSpy.downloadRemoteOps.and.resolveTo(fileSnapshotDownloadResult);
const mockProvider = {
isReady: () => Promise.resolve(true),
supportsOperationSync: true,
setLastServerSeq: jasmine.createSpy('setLastServerSeq').and.resolveTo(),
} as unknown as OperationSyncCapable;
await expectAsync(
service.downloadRemoteOps(mockProvider, { isNeverSynced: true }),
).toBeResolved();
expect(syncHydrationServiceSpy.hydrateFromRemoteSync).toHaveBeenCalled();
expect(opLogStoreSpy.markRejected).toHaveBeenCalledOnceWith([
'sync-provider-setup',
]);
});
it('does NOT throw LocalDataConflictError when store + pending ops contain only example tasks (#7985)', async () => {
opLogStoreSpy.getUnsynced.and.returnValue(
Promise.resolve([exampleCreateEntry('ex-task-1')]),
@ -1700,7 +1746,7 @@ describe('OperationLogSyncService', () => {
syncHydrationServiceSpy.hydrateFromRemoteSync.and.resolveTo();
downloadServiceSpy.downloadRemoteOps.and.returnValue(
Promise.resolve(fileSnapshotDownloadResult as any),
Promise.resolve(fileSnapshotDownloadResult),
);
const mockProvider = {
@ -1747,7 +1793,7 @@ describe('OperationLogSyncService', () => {
);
downloadServiceSpy.downloadRemoteOps.and.returnValue(
Promise.resolve(fileSnapshotDownloadResult as any),
Promise.resolve(fileSnapshotDownloadResult),
);
const mockProvider = {
@ -1774,7 +1820,7 @@ describe('OperationLogSyncService', () => {
} as any);
downloadServiceSpy.downloadRemoteOps.and.returnValue(
Promise.resolve(fileSnapshotDownloadResult as any),
Promise.resolve(fileSnapshotDownloadResult),
);
const mockProvider = {
@ -2656,7 +2702,7 @@ describe('OperationLogSyncService', () => {
const mockProvider = {
supportsOperationSync: true,
setLastServerSeq: jasmine.createSpy('setLastServerSeq').and.resolveTo(),
} as any;
} as unknown as OperationSyncCapable;
await service.forceUploadLocalState(mockProvider);
@ -3230,6 +3276,55 @@ describe('OperationLogSyncService', () => {
expect(opLogStoreSpy.runRemoteStateReplacement).not.toHaveBeenCalled();
});
it('should restore device-local sync settings before validating a file snapshot', async () => {
const mockStore = TestBed.inject(MockStore);
mockStore.overrideSelector(selectSyncConfig, {
...DEFAULT_GLOBAL_CONFIG.sync,
syncProvider: SyncProviderId.WebDAV,
isEnabled: true,
isEncryptionEnabled: true,
syncInterval: 23,
isManualSyncOnly: true,
});
mockStore.refreshState();
const wireSnapshot = stripLocalOnlySyncSettingsFromAppData({
globalConfig: DEFAULT_GLOBAL_CONFIG,
});
downloadServiceSpy.downloadRemoteOps.and.resolveTo({
newOps: [],
needsFullStateUpload: false,
success: true,
providerMode: 'fileSnapshotOps',
failedFileCount: 0,
snapshotState: wireSnapshot,
latestServerSeq: 1,
});
validateStateServiceSpy.validateAndRepair.and.resolveTo({
isValid: true,
wasRepaired: false,
});
const mockProvider = {
supportsOperationSync: true,
setLastServerSeq: jasmine.createSpy('setLastServerSeq').and.resolveTo(),
} as unknown as OperationSyncCapable;
await service.forceDownloadRemoteState(mockProvider);
expect(validateStateServiceSpy.validateAndRepair).toHaveBeenCalledOnceWith(
jasmine.objectContaining({
globalConfig: jasmine.objectContaining({
sync: jasmine.objectContaining({
syncProvider: SyncProviderId.WebDAV,
isEnabled: true,
isEncryptionEnabled: true,
syncInterval: 23,
isManualSyncOnly: true,
}),
}),
}),
);
});
it('should NOT advance the cursor past 0 when replay blocks on a migration failure', async () => {
downloadServiceSpy.downloadRemoteOps.and.resolveTo({
newOps: [makeRemoteOp()],
@ -4025,6 +4120,81 @@ describe('OperationLogSyncService', () => {
expect(result.kind).toBe('ops_processed');
});
it('should discard initial provider setup only after the incoming import commits', async () => {
const incomingSyncImport = createIncomingSyncImport();
const setupEntry = createProviderSetupEntry();
downloadServiceSpy.downloadRemoteOps.and.resolveTo({
newOps: [incomingSyncImport],
success: true,
providerMode: 'superSyncOps',
failedFileCount: 0,
latestServerSeq: 42,
});
opLogStoreSpy.getUnsynced.and.resolveTo([setupEntry]);
remoteOpsProcessingServiceSpy.processRemoteOps.and.resolveTo({
localWinOpsCreated: 0,
allOpsFilteredBySyncImport: false,
filteredOpCount: 0,
isLocalUnsyncedImport: false,
blockedByIncompatibleOp: true,
});
const mockProvider = {
supportsOperationSync: true,
setLastServerSeq: jasmine.createSpy('setLastServerSeq').and.resolveTo(),
} as unknown as OperationSyncCapable;
const result = await service.downloadRemoteOps(mockProvider, {
isNeverSynced: true,
});
expect(result.kind).toBe('blocked_incompatible');
expect(opLogStoreSpy.markRejected).not.toHaveBeenCalled();
expect(mockProvider.setLastServerSeq).not.toHaveBeenCalled();
remoteOpsProcessingServiceSpy.processRemoteOps.and.resolveTo({
localWinOpsCreated: 0,
allOpsFilteredBySyncImport: false,
filteredOpCount: 0,
isLocalUnsyncedImport: false,
blockedByIncompatibleOp: true,
committedFullStateOpIds: [incomingSyncImport.id],
});
const committedPrefixResult = await service.downloadRemoteOps(mockProvider, {
isNeverSynced: true,
});
expect(committedPrefixResult.kind).toBe('blocked_incompatible');
expect(opLogStoreSpy.markRejected).toHaveBeenCalledOnceWith([setupEntry.op.id]);
expect(mockProvider.setLastServerSeq).not.toHaveBeenCalled();
const processingError = new Error('deferred drain failed');
opLogStoreSpy.markRejected.calls.reset();
opLogStoreSpy.getOpById.and.resolveTo(undefined);
remoteOpsProcessingServiceSpy.processRemoteOps.and.rejectWith(processingError);
await expectAsync(
service.downloadRemoteOps(mockProvider, { isNeverSynced: true }),
).toBeRejectedWith(processingError);
expect(opLogStoreSpy.markRejected).not.toHaveBeenCalled();
for (const applicationStatus of ['applied', 'archive_pending', 'failed'] as const) {
opLogStoreSpy.markRejected.calls.reset();
opLogStoreSpy.getOpById.and.resolveTo({
seq: 2,
op: incomingSyncImport,
appliedAt: Date.now(),
source: 'remote',
applicationStatus,
});
await expectAsync(
service.downloadRemoteOps(mockProvider, { isNeverSynced: true }),
).toBeRejectedWith(processingError);
expect(opLogStoreSpy.markRejected).toHaveBeenCalledOnceWith([setupEntry.op.id]);
}
});
it('should show conflict dialog for incoming SYNC_IMPORT when client has pending meaningful ops', async () => {
const incomingSyncImport = createIncomingSyncImport();
@ -4345,7 +4515,7 @@ describe('OperationLogSyncService', () => {
const mockProvider = {
isReady: () => Promise.resolve(true),
} as any;
} as unknown as OperationSyncCapable;
const result = await service.uploadPendingOps(mockProvider);
@ -4405,7 +4575,7 @@ describe('OperationLogSyncService', () => {
const mockProvider = {
isReady: () => Promise.resolve(true),
} as any;
} as unknown as OperationSyncCapable;
const result = await service.uploadPendingOps(mockProvider);
@ -4463,6 +4633,113 @@ describe('OperationLogSyncService', () => {
expect(result.kind).toBe('completed');
});
it('should reject a live provider setup op after silently applying a piggybacked import on initial sync', async () => {
const piggybackedSyncImport: Operation = {
id: 'import-1',
clientId: 'client-B',
actionType: ActionType.LOAD_ALL_DATA,
opType: OpType.SyncImport,
entityType: 'ALL',
payload: {},
vectorClock: { clientB: 5 },
timestamp: Date.now(),
schemaVersion: 1,
};
const setupEntry = createProviderSetupEntry();
uploadServiceSpy.uploadPendingOps.and.resolveTo({
uploadedCount: 1,
piggybackedOps: [piggybackedSyncImport],
rejectedCount: 0,
rejectedOps: [],
selectedPendingOps: [setupEntry],
});
opLogStoreSpy.getUnsynced.and.resolveTo([setupEntry]);
remoteOpsProcessingServiceSpy.processRemoteOps.and.resolveTo({
localWinOpsCreated: 0,
allOpsFilteredBySyncImport: false,
filteredOpCount: 0,
isLocalUnsyncedImport: false,
blockedByIncompatibleOp: false,
committedFullStateOpIds: [piggybackedSyncImport.id],
});
const mockProvider = {
isReady: () => Promise.resolve(true),
} as unknown as OperationSyncCapable;
const result = await service.uploadPendingOps(mockProvider, {
isNeverSynced: true,
});
expect(
syncImportConflictDialogServiceSpy.showConflictDialog,
).not.toHaveBeenCalled();
expect(opLogStoreSpy.markRejected).toHaveBeenCalledOnceWith([
'sync-provider-setup',
]);
expect(remoteOpsProcessingServiceSpy.processRemoteOps).toHaveBeenCalledWith([
piggybackedSyncImport,
]);
expect(result.kind).toBe('completed');
});
it('should keep the initial provider setup op pending when piggybacked import processing is blocked', async () => {
const piggybackedSyncImport: Operation = {
id: 'import-1',
clientId: 'client-B',
actionType: ActionType.LOAD_ALL_DATA,
opType: OpType.SyncImport,
entityType: 'ALL',
payload: {},
vectorClock: { clientB: 5 },
timestamp: Date.now(),
schemaVersion: 1,
};
const setupEntry = createProviderSetupEntry();
uploadServiceSpy.uploadPendingOps.and.resolveTo({
uploadedCount: 1,
piggybackedOps: [piggybackedSyncImport],
rejectedCount: 0,
rejectedOps: [],
selectedPendingOps: [setupEntry],
});
opLogStoreSpy.getUnsynced.and.resolveTo([setupEntry]);
remoteOpsProcessingServiceSpy.processRemoteOps.and.resolveTo({
localWinOpsCreated: 0,
allOpsFilteredBySyncImport: false,
filteredOpCount: 0,
isLocalUnsyncedImport: false,
blockedByIncompatibleOp: true,
});
const mockProvider = {
isReady: () => Promise.resolve(true),
} as unknown as OperationSyncCapable;
const result = await service.uploadPendingOps(mockProvider, {
isNeverSynced: true,
});
expect(result.kind).toBe('blocked_incompatible');
expect(opLogStoreSpy.markRejected).not.toHaveBeenCalled();
remoteOpsProcessingServiceSpy.processRemoteOps.and.resolveTo({
localWinOpsCreated: 0,
allOpsFilteredBySyncImport: false,
filteredOpCount: 0,
isLocalUnsyncedImport: false,
blockedByIncompatibleOp: true,
committedFullStateOpIds: [piggybackedSyncImport.id],
});
const committedPrefixResult = await service.uploadPendingOps(mockProvider, {
isNeverSynced: true,
});
expect(committedPrefixResult.kind).toBe('blocked_incompatible');
expect(opLogStoreSpy.markRejected).toHaveBeenCalledOnceWith([setupEntry.op.id]);
});
it('should flush again before checking piggybacked SYNC_IMPORT conflicts', async () => {
const piggybackedSyncImport: Operation = {
id: 'import-1',

View file

@ -61,11 +61,16 @@ import { selectSyncConfig } from '../../features/config/store/global-config.redu
import {
applyLocalOnlySyncSettingsToAppData,
LocalOnlySyncSettings,
stripLocalOnlySyncSettingsFromAppData,
} from '../../features/config/local-only-sync-settings.util';
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
import { OperationApplierService } from '../apply/operation-applier.service';
import { processDeferredActions } from './process-deferred-actions-flush.util';
type RemoteOpsProcessingResult = Awaited<
ReturnType<RemoteOpsProcessingService['processRemoteOps']>
>;
/**
* Orchestrates synchronization of the Operation Log with remote storage.
*
@ -268,6 +273,8 @@ export class OperationLogSyncService {
};
if (result.piggybackedOps.length > 0) {
let startupOpIdsToDiscard: string[] = [];
let startupCleanupFullStateOpId: string | undefined;
// Check for piggybacked SYNC_IMPORT — mirrors the download path check (lines 552-604).
// Without this, a SYNC_IMPORT from another client arriving as a piggybacked op
// would silently replace local state via processRemoteOps().
@ -323,7 +330,8 @@ export class OperationLogSyncService {
// against the import (SyncImportFilterService). Only reachable in the narrow window
// where example tasks are created on a still-empty server and uploaded just as a
// remote import arrives; afterInitialSyncDoneStrict$ shrinks it further.
await this._discardExampleTaskOps(piggybackedConflict.discardablePendingOpIds);
startupOpIdsToDiscard = piggybackedConflict.discardablePendingOpIds;
startupCleanupFullStateOpId = fullStateOp.id;
OpLog.normal(
`OperationLogSyncService: Accepting piggybacked ${fullStateOp.opType} from client ` +
`${fullStateOp.clientId} without conflict dialog; ` +
@ -332,8 +340,10 @@ export class OperationLogSyncService {
}
}
const processResult = await this.remoteOpsProcessingService.processRemoteOps(
const processResult = await this._processRemoteOpsWithStartupCleanup(
result.piggybackedOps,
startupCleanupFullStateOpId,
startupOpIdsToDiscard,
);
localWinOpsCreated = processResult.localWinOpsCreated;
// Validation failure (if any) is on the session-validation latch.
@ -559,9 +569,9 @@ export class OperationLogSyncService {
const hasLocalChanges = unsyncedOps.length > 0;
// Collected here, applied AFTER hydrateFromRemoteSync succeeds so a
// hydration failure doesn't permanently drop the pending example-create
// ops while leaving the user without the remote snapshot.
let exampleTaskOpIdsToDiscard: string[] = [];
// hydration failure doesn't permanently drop discardable startup ops
// while leaving the user without the remote snapshot.
let startupOpIdsToDiscard: string[] = [];
if (hasLocalChanges) {
// Throw LocalDataConflictError if unsynced ops contain meaningful user data
@ -585,12 +595,23 @@ export class OperationLogSyncService {
.map((entry) => entry.op.entityId)
.filter((id): id is string => id !== undefined),
);
const exampleTaskOpIds = exampleTaskEntries.map((entry) => entry.op.id);
// Nothing from this sync is persisted yet, so this live read reflects
// whether the client completed a prior sync cycle.
const isNeverSyncedAtSyncStart =
options?.isNeverSynced ?? !(await this.opLogStore.hasSyncedOps());
const pendingOpClassification = {
hasCompletedInitialSync: !isNeverSyncedAtSyncStart,
};
const discardableStartupOpIds =
this.syncImportConflictGateService.getDiscardablePendingOpIds(
unsyncedOps,
pendingOpClassification,
);
const hasMeaningfulUserData =
this.syncImportConflictGateService.hasMeaningfulPendingOps(unsyncedOps) ||
this.syncLocalStateService.hasMeaningfulStoreData(exampleTaskIds);
this.syncImportConflictGateService.hasMeaningfulPendingOps(
unsyncedOps,
pendingOpClassification,
) || this.syncLocalStateService.hasMeaningfulStoreData(exampleTaskIds);
if (hasMeaningfulUserData) {
// SPAP-9: before surfacing the binary USE_LOCAL/USE_REMOTE dialog, use
@ -674,15 +695,15 @@ export class OperationLogSyncService {
// gate === 'apply-snapshot': the remote snapshot strictly dominates the
// local clock, so local holds nothing the snapshot lacks. Adopt the
// snapshot without a dialog by falling through to hydration below.
exampleTaskOpIdsToDiscard = exampleTaskOpIds;
startupOpIdsToDiscard = discardableStartupOpIds;
OpLog.normal(
'OperationLogSyncService: Remote snapshot strictly ahead of local clock — ' +
'applying snapshot without conflict dialog.',
);
} else {
// Defer the markRejected call until hydration has succeeded — see
// the declaration of exampleTaskOpIdsToDiscard above for rationale.
exampleTaskOpIdsToDiscard = exampleTaskOpIds;
// the declaration of startupOpIdsToDiscard above for rationale.
startupOpIdsToDiscard = discardableStartupOpIds;
// Only system/config ops AND no meaningful store data - proceed with download
OpLog.normal(
`OperationLogSyncService: Client has ${unsyncedOps.length} unsynced ops but no meaningful user data. ` +
@ -747,10 +768,10 @@ export class OperationLogSyncService {
);
// Now that the remote snapshot is applied, it's safe to drop the
// example-create ops we previously decided were obsolete. Doing this
// startup ops we previously decided were obsolete. Doing this
// after hydration ensures a hydration failure leaves the queue intact
// so the next attempt can retry.
await this._discardExampleTaskOps(exampleTaskOpIdsToDiscard);
await this._discardStartupOps(startupOpIdsToDiscard);
// CRITICAL FIX: Write recentOps to IndexedDB after snapshot hydration.
// File-based providers return ALL recentOps on every download, relying on
@ -889,6 +910,8 @@ export class OperationLogSyncService {
isNeverSynced: options?.isNeverSynced,
},
);
let startupOpIdsToDiscard: string[] = [];
let startupCleanupFullStateOpId: string | undefined;
if (incomingConflict.fullStateOp) {
const { fullStateOp, pendingOps, dialogData } = incomingConflict;
// Existing synced store data is not a conflict here. Prompt only when
@ -915,7 +938,8 @@ export class OperationLogSyncService {
// the session-validation latch — wrapper reads it. (#7330)
return { kind: 'no_new_ops' };
} else {
await this._discardExampleTaskOps(incomingConflict.discardablePendingOpIds);
startupOpIdsToDiscard = incomingConflict.discardablePendingOpIds;
startupCleanupFullStateOpId = fullStateOp.id;
OpLog.normal(
`OperationLogSyncService: Accepting incoming ${fullStateOp.opType} from client ` +
`${fullStateOp.clientId} without conflict dialog; ` +
@ -924,8 +948,10 @@ export class OperationLogSyncService {
}
}
const processResult = await this.remoteOpsProcessingService.processRemoteOps(
const processResult = await this._processRemoteOpsWithStartupCleanup(
result.newOps,
startupCleanupFullStateOpId,
startupOpIdsToDiscard,
);
if (processResult.blockedByIncompatibleOp) {
@ -1010,17 +1036,74 @@ export class OperationLogSyncService {
};
}
private async _processRemoteOpsWithStartupCleanup(
remoteOps: Operation[],
fullStateOpId: string | undefined,
startupOpIds: string[],
): Promise<RemoteOpsProcessingResult> {
try {
const result = await this.remoteOpsProcessingService.processRemoteOps(remoteOps);
await this._discardStartupOpsIfFullStateCommitted(
fullStateOpId,
startupOpIds,
result.committedFullStateOpIds,
);
return result;
} catch (error) {
try {
// The reducer/apply transaction can commit the full-state op before a
// later validation or deferred-action drain throws. Query persistence
// so obsolete startup ops cannot replay after an already-applied import.
await this._discardStartupOpsIfFullStateCommitted(
fullStateOpId,
startupOpIds,
[],
true,
);
} catch (cleanupError) {
// Preserve the primary processing error. A later retry can re-check and
// clean up once persistence is available again.
OpLog.err(
'OperationLogSyncService: Failed to verify startup-op cleanup after remote processing error.',
{ name: (cleanupError as Error | undefined)?.name },
);
}
throw error;
}
}
private async _discardStartupOpsIfFullStateCommitted(
fullStateOpId: string | undefined,
startupOpIds: string[],
committedFullStateOpIds: string[] = [],
acceptReducerCommittedFailureStatus: boolean = false,
): Promise<void> {
if (!fullStateOpId || startupOpIds.length === 0) {
return;
}
const applicationStatus = (await this.opLogStore.getOpById(fullStateOpId))
?.applicationStatus;
const isCommitted =
committedFullStateOpIds.includes(fullStateOpId) ||
applicationStatus === 'applied' ||
(acceptReducerCommittedFailureStatus &&
(applicationStatus === 'archive_pending' || applicationStatus === 'failed'));
if (isCommitted) {
await this._discardStartupOps(startupOpIds);
}
}
/**
* Rejects the auto-generated startup example-task ops so they are NOT uploaded
* after a SYNC_IMPORT is accepted silently. They were already excluded from the
* conflict gate's "meaningful work" check (see SyncImportConflictGateService); the
* import replaces local state, so rejecting them keeps the op-log consistent with
* the just-applied remote data instead of re-uploading throwaway onboarding tasks.
* Rejects startup-only ops so they are NOT uploaded after an authoritative remote
* state is accepted silently. They were already excluded from the conflict gate's
* "meaningful work" check (see SyncImportConflictGateService); rejecting them keeps
* the op-log consistent with the just-applied remote data.
*
* These ids always come from getUnsynced() (local pending ops, never remote ops),
* so a remote `isExampleTask` flag can never reach this path.
* so a remote startup marker can never reach this path.
*/
private async _discardExampleTaskOps(opIds: string[]): Promise<void> {
private async _discardStartupOps(opIds: string[]): Promise<void> {
if (opIds.length > 0) {
await this.opLogStore.markRejected(opIds);
}
@ -1260,8 +1343,24 @@ export class OperationLogSyncService {
const migratedRemoteOps = this._preflightRemoteOperations(result.newOps);
const currentSyncConfig = await firstValueFrom(this.store.select(selectSyncConfig));
const localOnlySyncSettings: LocalOnlySyncSettings = {
isEnabled: currentSyncConfig.isEnabled,
isEncryptionEnabled: currentSyncConfig.isEncryptionEnabled,
syncProvider: currentSyncConfig.syncProvider,
syncInterval: currentSyncConfig.syncInterval,
isManualSyncOnly: currentSyncConfig.isManualSyncOnly,
};
let snapshotState = result.snapshotState as Record<string, unknown> | undefined;
if (hasSnapshotState && snapshotState) {
// File providers intentionally omit device-local schedule fields and null
// the provider on the wire. Restore this device's values before schema
// validation so a valid transport snapshot is locally replayable.
snapshotState = applyLocalOnlySyncSettingsToAppData(
stripLocalOnlySyncSettingsFromAppData(snapshotState),
localOnlySyncSettings,
) as Record<string, unknown>;
const validation = await this.validateStateService.validateAndRepair(snapshotState);
if (!validation.isValid) {
throw new Error(
@ -1298,14 +1397,6 @@ export class OperationLogSyncService {
baselineGlobalConfig['sync'] && typeof baselineGlobalConfig['sync'] === 'object'
? (baselineGlobalConfig['sync'] as Record<string, unknown>)
: {};
const currentSyncConfig = await firstValueFrom(this.store.select(selectSyncConfig));
const localOnlySyncSettings: LocalOnlySyncSettings = {
isEnabled: currentSyncConfig.isEnabled,
isEncryptionEnabled: currentSyncConfig.isEncryptionEnabled,
syncProvider: currentSyncConfig.syncProvider,
syncInterval: currentSyncConfig.syncInterval,
isManualSyncOnly: currentSyncConfig.isManualSyncOnly,
};
// getDefaultMainModelData intentionally excludes globalConfig. Add a
// default config shell before applying the canonical device-local fields
// so an interrupted rebuild can hydrate enough configuration to sync again.

View file

@ -611,6 +611,40 @@ describe('RemoteOpsProcessingService', () => {
expect(result.blockedByIncompatibleOp).toBe(true);
});
it('should report a committed full-state prefix before a blocked suffix', async () => {
const repair: Operation = {
id: 'repair-prefix',
opType: OpType.Repair,
actionType: ActionType.REPAIR_AUTO,
entityType: 'ALL',
payload: {},
clientId: 'client-1',
vectorClock: { client1: 1 },
timestamp: Date.now(),
schemaVersion: 1,
};
const futureOp: Operation = {
id: 'future-suffix',
opType: OpType.Update,
actionType: ActionType.TASK_SHARED_UPDATE,
entityType: 'TASK',
entityId: 'task-1',
payload: {},
clientId: 'client-2',
vectorClock: { client2: 1 },
timestamp: Date.now(),
schemaVersion: 2,
};
opLogStoreSpy.hasOp.and.resolveTo(false);
opLogStoreSpy.append.and.resolveTo(1);
const result = await service.processRemoteOps([repair, futureOp]);
expect(result.blockedByIncompatibleOp).toBeTrue();
expect(result.committedFullStateOpIds).toEqual([repair.id]);
expect(operationApplierServiceSpy.applyOperations).toHaveBeenCalled();
});
it('should show error snackbar and abort if version is below minimum supported', async () => {
const remoteOps: Operation[] = [
{ id: 'op1', schemaVersion: MIN_SUPPORTED_SCHEMA_VERSION - 1 } as Operation,

View file

@ -7,9 +7,9 @@ import {
ConflictResult,
EntityConflict,
extractFullStateFromPayload,
FULL_STATE_OP_TYPES,
isWrappedFullStatePayload,
Operation,
OpType,
VectorClock,
} from '../core/operation.types';
import { OpLog } from '../../core/log';
@ -105,6 +105,12 @@ export class RemoteOpsProcessingService {
filteringImport?: Operation;
isLocalUnsyncedImport: boolean;
blockedByIncompatibleOp: boolean;
/**
* Full-state operations whose application completed (or was already
* deduplicated as applied) before this result was returned. Populated even
* when a later incompatible op blocks the remaining batch suffix.
*/
committedFullStateOpIds?: string[];
}> {
// Validation failure surfaces via the SyncSessionValidationService latch
// (#7330). `validateAfterSync` and the conflict-resolution validation path
@ -248,18 +254,16 @@ export class RemoteOpsProcessingService {
}
// ─────────────────────────────────────────────────────────────────────────
// STEP 3: Check for full-state operations (SYNC_IMPORT / BACKUP_IMPORT)
// STEP 3: Check for full-state operations
// These replace the entire state, so conflict detection doesn't apply.
// ─────────────────────────────────────────────────────────────────────────
const hasFullStateOp = validOps.some(
(op) => op.opType === OpType.SyncImport || op.opType === OpType.BackupImport,
);
const hasFullStateOp = validOps.some((op) => FULL_STATE_OP_TYPES.has(op.opType));
if (hasFullStateOp) {
OpLog.normal(
'RemoteOpsProcessingService: Full-state operation detected, skipping conflict detection.',
);
await this.applyNonConflictingOps(validOps);
const committedFullStateOpIds = await this.applyNonConflictingOps(validOps);
// Clean Slate Semantics: SYNC_IMPORT/BACKUP_IMPORT replaces entire state.
// Local synced ops are NOT replayed - the import is an explicit user action
@ -272,6 +276,7 @@ export class RemoteOpsProcessingService {
filteredOpCount: 0,
isLocalUnsyncedImport: false,
blockedByIncompatibleOp,
committedFullStateOpIds,
};
}
@ -434,7 +439,7 @@ export class RemoteOpsProcessingService {
async applyNonConflictingOps(
ops: Operation[],
callerHoldsLock: boolean = false,
): Promise<void> {
): Promise<string[]> {
const locallyReplayableOps =
await this._withLocalOnlySyncSettingsForFullStateOps(ops);
@ -447,6 +452,7 @@ export class RemoteOpsProcessingService {
// to leak into the next sync window with stale clocks. (#7700)
let didApplyRemoteOps = false;
let primaryIncompleteError: IncompleteRemoteOperationsError | undefined;
let committedFullStateOpIds: string[] = [];
try {
// Core owns the generic crash-safety ordering. Angular diagnostics,
// validation, and user notifications stay in this service.
@ -464,6 +470,9 @@ export class RemoteOpsProcessingService {
});
didApplyRemoteOps = result.appendedOps.length > 0;
committedFullStateOpIds = result.appendedOps
.filter((op) => FULL_STATE_OP_TYPES.has(op.opType))
.map((op) => op.id);
if (result.skippedCount > 0) {
OpLog.verbose(
@ -526,6 +535,7 @@ export class RemoteOpsProcessingService {
}
}
}
return committedFullStateOpIds;
}
private async _withLocalOnlySyncSettingsForFullStateOps(
@ -569,11 +579,7 @@ export class RemoteOpsProcessingService {
}
private _isFullStateOperation(op: Operation): boolean {
return (
op.opType === OpType.SyncImport ||
op.opType === OpType.BackupImport ||
op.opType === OpType.Repair
);
return FULL_STATE_OP_TYPES.has(op.opType);
}
/**

View file

@ -118,13 +118,38 @@ describe('SyncImportConflictGateService', () => {
expect(result.dialogData).toBeDefined();
});
it('should protect a pending sync config change on a never-synced client', async () => {
it('should ignore the sync setup write on a never-synced client', async () => {
opLogStoreSpy.hasSyncedOps.and.resolveTo(false);
opLogStoreSpy.getUnsynced.and.resolveTo([createPendingConfigEntry()]);
const result = await service.checkIncomingFullStateConflict([createOperation()]);
expect(result.hasMeaningfulPending).toBeFalse();
expect(result.discardablePendingOpIds).toEqual(['local-config-update']);
expect(result.dialogData).toBeUndefined();
});
it('should protect non-update operations targeting the sync config coordinates', async () => {
opLogStoreSpy.hasSyncedOps.and.resolveTo(false);
opLogStoreSpy.getUnsynced.and.resolveTo([
createEntry(
createOperation({
id: 'local-config-delete',
actionType: ActionType.GLOBAL_CONFIG_UPDATE_SECTION,
opType: OpType.Delete,
entityType: 'GLOBAL_CONFIG',
entityId: 'sync',
payload: { sectionKey: 'sync' },
clientId: 'client-A',
vectorClock: { clientA: 1 },
}),
),
]);
const result = await service.checkIncomingFullStateConflict([createOperation()]);
expect(result.hasMeaningfulPending).toBeTrue();
expect(result.discardablePendingOpIds).toEqual([]);
expect(result.dialogData).toBeDefined();
});

View file

@ -1,6 +1,7 @@
import { inject, Injectable } from '@angular/core';
import { OperationLogStoreService } from '../persistence/operation-log-store.service';
import {
ActionType,
FULL_STATE_OP_TYPES,
Operation,
OperationLogEntry,
@ -10,6 +11,21 @@ import { OperationWriteFlushService } from './operation-write-flush.service';
import { SyncImportConflictData } from './dialog-sync-import-conflict/dialog-sync-import-conflict.component';
import { isExampleTaskCreateOp } from '../validation/is-example-task-op.util';
/**
* Enabling/configuring sync on a never-synced client necessarily writes the
* sync config section before the first download. That setup-only operation is
* not local user data divergence. Other GLOBAL_CONFIG sections remain protected.
*/
const isInitialSyncSetupOp = (entry: OperationLogEntry): boolean =>
entry.op.actionType === ActionType.GLOBAL_CONFIG_UPDATE_SECTION &&
entry.op.opType === OpType.Update &&
entry.op.entityType === 'GLOBAL_CONFIG' &&
entry.op.entityId === 'sync';
interface PendingOpClassificationOptions {
hasCompletedInitialSync: boolean;
}
export interface IncomingFullStateConflictGateResult {
fullStateOp?: Operation;
pendingOps: OperationLogEntry[];
@ -34,14 +50,20 @@ export class SyncImportConflictGateService {
private writeFlushService = inject(OperationWriteFlushService);
/**
* Every pending op is user work unless it is an onboarding example-task create.
* Every pending op is user work unless it is an onboarding example-task create,
* or the sync-section setup write on a client that has never completed sync.
* Full-state ops are always meaningful because applying a newer full-state op
* can invalidate their local import/repair semantics.
*
* The lifecycle default is deliberately conservative: a caller that does not
* know whether initial sync completed must protect startup-entity changes.
*/
hasMeaningfulPendingOps(ops: OperationLogEntry[]): boolean {
hasMeaningfulPendingOps(
ops: OperationLogEntry[],
options: PendingOpClassificationOptions = {
hasCompletedInitialSync: true,
},
): boolean {
return ops.some((entry) => {
if (FULL_STATE_OP_TYPES.has(entry.op.opType as OpType)) {
return true;
@ -49,10 +71,26 @@ export class SyncImportConflictGateService {
if (isExampleTaskCreateOp(entry)) {
return false;
}
if (isInitialSyncSetupOp(entry)) {
return options.hasCompletedInitialSync;
}
return true;
});
}
getDiscardablePendingOpIds(
ops: OperationLogEntry[],
options: PendingOpClassificationOptions,
): string[] {
return ops
.filter(
(entry) =>
isExampleTaskCreateOp(entry) ||
(!options.hasCompletedInitialSync && isInitialSyncSetupOp(entry)),
)
.map((entry) => entry.op.id);
}
/**
* @param options.preCapturedPendingOps - Exact pending ops selected by the upload
* round. The piggyback path unions this snapshot with a live read so it
@ -89,14 +127,6 @@ export class SyncImportConflictGateService {
? this._mergePendingOps(options.preCapturedPendingOps, livePendingOps)
: livePendingOps;
// Example-task ops that the caller may reject when it accepts the import silently.
// These must come from a LIVE read: with a pre-captured snapshot, example ops
// accepted earlier in the same upload round are already marked synced and must
// not be re-marked rejected by the caller.
const discardablePendingOpIds = livePendingOps
.filter(isExampleTaskCreateOp)
.map((entry) => entry.op.id);
// Preserve the cheap example-task-only path. If nothing could be meaningful
// even for a synced client, there is no reason to read sync history.
const canContainMeaningfulPending = this.hasMeaningfulPendingOps(pendingOps);
@ -105,13 +135,28 @@ export class SyncImportConflictGateService {
fullStateOp,
pendingOps,
hasMeaningfulPending: false,
discardablePendingOpIds,
discardablePendingOpIds: this.getDiscardablePendingOpIds(livePendingOps, {
hasCompletedInitialSync: true,
}),
};
}
const isNeverSynced =
options.isNeverSynced ?? !(await this.opLogStore.hasSyncedOps());
const hasMeaningfulPending = this.hasMeaningfulPendingOps(pendingOps);
const classificationOptions = {
hasCompletedInitialSync: !isNeverSynced,
};
const hasMeaningfulPending = this.hasMeaningfulPendingOps(
pendingOps,
classificationOptions,
);
// Only live pending ops may be rejected. A pre-captured upload snapshot can
// include ops already acknowledged by the server, which must not be rewritten
// as rejected locally.
const discardablePendingOpIds = this.getDiscardablePendingOpIds(
livePendingOps,
classificationOptions,
);
const result = {
fullStateOp,

View file

@ -36,13 +36,13 @@ describe('Example-task SYNC_IMPORT gate (integration)', () => {
},
});
const configOp = (): Operation =>
const configOp = (sectionKey = 'sync'): Operation =>
local.createOperation({
actionType: ActionType.GLOBAL_CONFIG_UPDATE_SECTION,
opType: OpType.Update,
entityType: 'GLOBAL_CONFIG',
entityId: 'sync',
payload: { sectionKey: 'sync' },
entityId: sectionKey,
payload: { sectionKey },
});
const incomingSyncImport = (): Operation =>
@ -89,24 +89,20 @@ describe('Example-task SYNC_IMPORT gate (integration)', () => {
);
});
it('treats a pending config change as meaningful user work (widened gate) and shows the dialog', async () => {
// The gate deliberately has NO entity-wide exemptions beyond example-task
// creates: GLOBAL_CONFIG carries synced preferences, so silently discarding
// a pending config op on an incoming SYNC_IMPORT would lose user work.
it('treats the never-synced provider setup write as discardable startup work', async () => {
const example = exampleTaskOp('example-task-1');
await storeService.append(configOp(), 'local');
const config = configOp();
await storeService.append(config, 'local');
await storeService.append(example, 'local');
const result = await gate.checkIncomingFullStateConflict([incomingSyncImport()]);
expect(result.hasMeaningfulPending).toBeTrue();
expect(result.dialogData).toBeDefined();
// Example ops stay listed so the caller can discard them if the user
// accepts the import.
expect(result.discardablePendingOpIds).toEqual([example.id]);
expect(result.hasMeaningfulPending).toBeFalse();
expect(result.dialogData).toBeUndefined();
expect(result.discardablePendingOpIds.sort()).toEqual([config.id, example.id].sort());
});
it('actually excludes example-task ops from getUnsynced after markRejected (so they are not uploaded)', async () => {
it('actually excludes discardable startup ops from getUnsynced after markRejected', async () => {
const config = configOp();
const example = exampleTaskOp('example-task-1');
await storeService.append(config, 'local');
@ -116,10 +112,21 @@ describe('Example-task SYNC_IMPORT gate (integration)', () => {
await storeService.markRejected(result.discardablePendingOpIds);
const remaining = (await storeService.getUnsynced()).map((e) => e.op.id);
expect(remaining).toContain(config.id);
expect(remaining).not.toContain(config.id);
expect(remaining).not.toContain(example.id);
});
it('keeps another config section meaningful on a never-synced client', async () => {
const config = configOp('productivityHacks');
await storeService.append(config, 'local');
const result = await gate.checkIncomingFullStateConflict([incomingSyncImport()]);
expect(result.hasMeaningfulPending).toBeTrue();
expect(result.dialogData).toBeDefined();
expect(result.discardablePendingOpIds).toEqual([]);
});
it('shows the dialog (and still lists the example id) when real user work is also pending', async () => {
const example = exampleTaskOp('example-task-1');
const realTaskUpdate = local.createOperation({