fix(sync): preserve actionable recovery state

This commit is contained in:
Johannes Millan 2026-07-10 22:50:33 +02:00
parent eabfd84436
commit bfdc63e0c6
9 changed files with 199 additions and 33 deletions

View file

@ -1066,6 +1066,19 @@ describe('SyncWrapperService', () => {
});
});
it('should preserve an existing persistent recovery action for incomplete remote work', async () => {
mockSnackService.hasPendingPersistentAction.and.returnValue(true);
mockSyncService.downloadRemoteOps.and.rejectWith(
new IncompleteRemoteOperationsError(new Error('archive failed')),
);
const result = await service.sync();
expect(result).toBe('HANDLED_ERROR');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockSnackService.open).not.toHaveBeenCalled();
});
it('should handle PotentialCorsError with snack message', async () => {
mockSyncService.downloadRemoteOps.and.returnValue(
Promise.reject(new PotentialCorsError('https://example.com')),

View file

@ -710,11 +710,13 @@ export class SyncWrapperService {
return 'HANDLED_ERROR';
} else if (error instanceof IncompleteRemoteOperationsError) {
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg: T.F.SYNC.S.INCOMPLETE_REMOTE_OPERATIONS,
type: 'ERROR',
config: { duration: 0 },
});
if (!this._snackService.hasPendingPersistentAction()) {
this._snackService.open({
msg: T.F.SYNC.S.INCOMPLETE_REMOTE_OPERATIONS,
type: 'ERROR',
config: { duration: 0 },
});
}
return 'HANDLED_ERROR';
} else if (
error instanceof AuthFailSPError ||

View file

@ -6,6 +6,7 @@ import { VectorClockService } from '../sync/vector-clock.service';
import {
ActionType,
Operation,
OperationLogEntry,
OpType,
EntityType,
VectorClock,
@ -29,7 +30,12 @@ import {
MAX_VECTOR_CLOCK_SIZE,
} from '../core/operation-log.const';
import { IndexedDBOpenError } from '../core/errors/indexed-db-open.error';
import { FULL_STATE_OPS_META_KEY, SINGLETON_KEY, STORE_NAMES } from './db-keys.const';
import {
FULL_STATE_OPS_META_KEY,
OPS_INDEXES,
SINGLETON_KEY,
STORE_NAMES,
} from './db-keys.const';
import { ArchiveModel } from '../../features/time-tracking/time-tracking.model';
describe('OperationLogStoreService', () => {
@ -1555,6 +1561,32 @@ describe('OperationLogStoreService', () => {
});
describe('markFailed', () => {
const seedLegacyTerminalRemoteFailure = async (op: Operation): Promise<void> => {
await service.append(op, 'remote', { pendingApply: true });
for (let retry = 0; retry < 4; retry++) {
await service.markFailed([op.id]);
}
await service.markRejected([op.id]);
const adapter = (
service as unknown as {
_adapter: OpLogDbAdapter;
}
)._adapter;
await adapter.transaction([STORE_NAMES.OPS], 'readwrite', async (tx) => {
const entry = await tx.getFromIndex<OperationLogEntry>(
STORE_NAMES.OPS,
OPS_INDEXES.BY_ID,
op.id,
);
if (!entry) {
throw new Error('Expected seeded legacy operation');
}
entry.applicationStatus = undefined;
await tx.put(STORE_NAMES.OPS, entry);
});
};
it('should increment retry count', async () => {
const op = createTestOperation();
await service.append(op, 'remote', { pendingApply: true });
@ -1594,11 +1626,7 @@ describe('OperationLogStoreService', () => {
it('should re-quarantine legacy terminal remote failures', async () => {
const op = createTestOperation();
await service.append(op, 'remote', { pendingApply: true });
for (let retry = 0; retry < 4; retry++) {
await service.markFailed([op.id]);
}
await service.markRejected([op.id]);
await seedLegacyTerminalRemoteFailure(op);
expect(await service.recoverLegacyTerminalRemoteFailures()).toBe(1);
@ -1610,25 +1638,32 @@ describe('OperationLogStoreService', () => {
it('should run the legacy terminal remote failure repair only once', async () => {
const firstLegacyOp = createTestOperation({ entityId: 'first-legacy' });
await service.append(firstLegacyOp, 'remote', { pendingApply: true });
for (let retry = 0; retry < 4; retry++) {
await service.markFailed([firstLegacyOp.id]);
}
await service.markRejected([firstLegacyOp.id]);
await seedLegacyTerminalRemoteFailure(firstLegacyOp);
expect(await service.recoverLegacyTerminalRemoteFailures()).toBe(1);
const laterLegacyOp = createTestOperation({ entityId: 'later-legacy' });
await service.append(laterLegacyOp, 'remote', { pendingApply: true });
for (let retry = 0; retry < 4; retry++) {
await service.markFailed([laterLegacyOp.id]);
}
await service.markRejected([laterLegacyOp.id]);
await seedLegacyTerminalRemoteFailure(laterLegacyOp);
expect(await service.recoverLegacyTerminalRemoteFailures()).toBe(0);
expect((await service.getOpById(laterLegacyOp.id))?.rejectedAt).toBeDefined();
});
it('should leave legitimately rejected failed remote work untouched', async () => {
const op = createTestOperation({ entityId: 'legitimately-rejected' });
await service.append(op, 'remote', { pendingApply: true });
for (let retry = 0; retry < 4; retry++) {
await service.markFailed([op.id]);
}
await service.markRejected([op.id]);
expect(await service.recoverLegacyTerminalRemoteFailures()).toBe(0);
const stored = await service.getOpById(op.id);
expect(stored?.rejectedAt).toBeDefined();
expect(stored?.applicationStatus).toBe('failed');
});
it('should handle empty array', async () => {
await service.markFailed([]);
// Should not throw
@ -2139,6 +2174,30 @@ describe('OperationLogStoreService', () => {
lastTimeTrackingFlush: 0,
});
it('should atomically clear an interrupted raw-rebuild marker', async () => {
await service.runRemoteStateReplacement({
baselineState: { task: { ids: [], entities: {} } },
vectorClock: { remote: 1 },
schemaVersion: 4,
snapshotEntityKeys: [],
archiveYoung: createArchive('remote-young'),
archiveOld: createArchive('remote-old'),
});
expect(await service.isRawRebuildIncomplete()).toBe(true);
await service.runDestructiveStateReplacement({
syncImportOp: createTestOperation({
opType: OpType.BackupImport,
entityType: 'ALL' as EntityType,
entityId: 'restored-backup',
payload: { task: { ids: [], entities: {} } },
}),
snapshotEntityKeys: [],
});
expect(await service.isRawRebuildIncomplete()).toBe(false);
});
it('should write archives in the same destructive replacement', async () => {
const archiveYoung = createArchive('young-task');
const archiveOld = createArchive('old-task');

View file

@ -1152,6 +1152,7 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
if (
entry.source === 'remote' &&
entry.rejectedAt !== undefined &&
entry.applicationStatus === undefined &&
(entry.retryCount ?? 0) >= 4
) {
entry.rejectedAt = undefined;
@ -2061,6 +2062,11 @@ export class OperationLogStoreService implements RemoteOperationApplyStorePort<O
FULL_STATE_OPS_META_KEY,
);
// This replacement supersedes any interrupted USE_REMOTE rebuild. Clear
// the marker in the same transaction as the restored/clean-slate
// baseline so a successful Undo cannot immediately re-enter recovery.
await tx.delete(STORE_NAMES.META, RAW_REBUILD_INCOMPLETE_META_KEY);
await tx.put(
STORE_NAMES.VECTOR_CLOCK,
{ clock: newVectorClock, lastUpdate: Date.now() },

View file

@ -101,7 +101,11 @@ describe('ImmediateUploadService', () => {
mockSyncWrapperService = {
isEncryptionOperationInProgress: false,
};
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
mockSnackService = jasmine.createSpyObj('SnackService', [
'open',
'hasPendingPersistentAction',
]);
mockSnackService.hasPendingPersistentAction.and.returnValue(false);
TestBed.configureTestingModule({
providers: [
@ -181,6 +185,21 @@ describe('ImmediateUploadService', () => {
});
}));
it('should preserve an existing persistent recovery action for incomplete remote work', fakeAsync(() => {
mockSnackService.hasPendingPersistentAction.and.returnValue(true);
mockSyncService.uploadPendingOps.and.rejectWith(
new IncompleteRemoteOperationsError(new Error('archive failed')),
);
service.initialize();
service.trigger();
tick(2000);
flush();
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockSnackService.open).not.toHaveBeenCalled();
}));
it('should report UNKNOWN_OR_CHANGED when a local-win follow-up lacks a mandatory encryption key', fakeAsync(() => {
mockSyncService.uploadPendingOps.and.returnValues(
Promise.resolve(completedResult({ uploadedCount: 1, localWinOpsCreated: 1 })),

View file

@ -330,11 +330,13 @@ export class ImmediateUploadService implements OnDestroy {
} catch (e) {
if (e instanceof IncompleteRemoteOperationsError) {
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg: T.F.SYNC.S.INCOMPLETE_REMOTE_OPERATIONS,
type: 'ERROR',
config: { duration: 0 },
});
if (!this._snackService.hasPendingPersistentAction()) {
this._snackService.open({
msg: T.F.SYNC.S.INCOMPLETE_REMOTE_OPERATIONS,
type: 'ERROR',
config: { duration: 0 },
});
}
return;
}

View file

@ -1064,6 +1064,50 @@ describe('OperationLogSyncService', () => {
);
});
it('should allow uploads after stranded-rebuild Undo clears the marker', async () => {
let isRawRebuildIncomplete = true;
opLogStoreSpy.isRawRebuildIncomplete.and.callFake(
async () => isRawRebuildIncomplete,
);
spyOn(service, 'forceDownloadRemoteState').and.rejectWith(
new Error('USE_REMOTE aborted: remote returned no data to rebuild from.'),
);
opLogStoreSpy.loadImportBackup.and.resolveTo({ savedAt: 4242 } as any);
backupServiceSpy.restoreImportBackup.and.callFake(async () => {
isRawRebuildIncomplete = false;
return true;
});
uploadServiceSpy.uploadPendingOps.and.resolveTo({
uploadedCount: 0,
piggybackedOps: [],
rejectedCount: 0,
rejectedOps: [],
});
const mockProvider = { isReady: () => Promise.resolve(true) } as any;
await expectAsync(service.downloadRemoteOps(mockProvider)).toBeRejected();
const undoSnack = snackServiceSpy.open.calls
.allArgs()
.map(([params]) => params)
.find(
(params) =>
typeof params === 'object' &&
params !== null &&
params.msg === T.F.SYNC.S.LOCAL_DATA_REPLACE_UNDO,
);
expect(undoSnack).toBeDefined();
if (typeof undoSnack !== 'object' || undoSnack === null || !undoSnack.actionFn) {
throw new Error('Expected the stranded-rebuild Undo action');
}
await undoSnack.actionFn();
await service.uploadPendingOps(mockProvider);
expect(backupServiceSpy.restoreImportBackup).toHaveBeenCalledWith(4242);
expect(uploadServiceSpy.uploadPendingOps).toHaveBeenCalled();
});
it('should not respawn the recovery snack while one is already showing', async () => {
opLogStoreSpy.isRawRebuildIncomplete.and.resolveTo(true);
spyOn(service, 'forceDownloadRemoteState').and.rejectWith(

View file

@ -61,7 +61,11 @@ describe('WsTriggeredDownloadService', () => {
// off it lazily (via Injector, to avoid a DI cycle). Provide a minimal mock so
// the real (heavily-dependent) service is never constructed in the unit test.
mockSyncWrapper = { isEncryptionOperationInProgress: false };
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
mockSnackService = jasmine.createSpyObj('SnackService', [
'open',
'hasPendingPersistentAction',
]);
mockSnackService.hasPendingPersistentAction.and.returnValue(false);
TestBed.configureTestingModule({
providers: [
@ -251,6 +255,21 @@ describe('WsTriggeredDownloadService', () => {
});
}));
it('should preserve an existing persistent recovery action for incomplete remote work', fakeAsync(() => {
mockSnackService.hasPendingPersistentAction.and.returnValue(true);
mockSyncService.downloadRemoteOps.and.rejectWith(
new IncompleteRemoteOperationsError(new Error('archive failed')),
);
service.start();
notification$.next({ latestSeq: 1 });
tick(500);
flushMicrotasks();
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('ERROR');
expect(mockSnackService.open).not.toHaveBeenCalled();
}));
it('should be idempotent when start is called twice', fakeAsync(() => {
service.start();
service.start();

View file

@ -176,11 +176,13 @@ export class WsTriggeredDownloadService implements OnDestroy {
err,
);
this._providerManager.setSyncStatus('ERROR');
this._snackService.open({
msg: T.F.SYNC.S.INCOMPLETE_REMOTE_OPERATIONS,
type: 'ERROR',
config: { duration: 0 },
});
if (!this._snackService.hasPendingPersistentAction()) {
this._snackService.open({
msg: T.F.SYNC.S.INCOMPLETE_REMOTE_OPERATIONS,
type: 'ERROR',
config: { duration: 0 },
});
}
return;
}
if (err instanceof AuthFailSPError || err instanceof MissingCredentialsSPError) {