From 082aca26e8d81c0f56af098a044a45708adff81e Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Mon, 15 Dec 2025 13:28:58 +0100 Subject: [PATCH] fix(sync-server): reduce operation and tombstone retention to 45 days Reduces storage per user by ~50% at steady state. Devices offline longer than 45 days will need a full resync via gap detection. --- .../super-sync-server/src/sync/sync.types.ts | 4 +- .../sync/sync-safety-backup.service.spec.ts | 367 ++++++++++++++++++ 2 files changed, 369 insertions(+), 2 deletions(-) create mode 100644 src/app/imex/sync/sync-safety-backup.service.spec.ts diff --git a/packages/super-sync-server/src/sync/sync.types.ts b/packages/super-sync-server/src/sync/sync.types.ts index 17f0d8a028..4be94bb9f0 100644 --- a/packages/super-sync-server/src/sync/sync.types.ts +++ b/packages/super-sync-server/src/sync/sync.types.ts @@ -347,8 +347,8 @@ export const DEFAULT_SYNC_CONFIG: SyncConfig = { downloadLimit: 1000, uploadRateLimit: { max: 100, windowMs: MS_PER_MINUTE }, downloadRateLimit: { max: 200, windowMs: MS_PER_MINUTE }, - tombstoneRetentionMs: 90 * MS_PER_DAY, // 90 days - opRetentionMs: 90 * MS_PER_DAY, // 90 days + tombstoneRetentionMs: 45 * MS_PER_DAY, // 45 days + opRetentionMs: 45 * MS_PER_DAY, // 45 days snapshotCacheTtlMs: 5 * MS_PER_MINUTE, // 5 minutes maxClockDriftMs: MS_PER_MINUTE, // 60 seconds maxOpAgeMs: 30 * MS_PER_DAY, // 30 days diff --git a/src/app/imex/sync/sync-safety-backup.service.spec.ts b/src/app/imex/sync/sync-safety-backup.service.spec.ts new file mode 100644 index 0000000000..3f388c4316 --- /dev/null +++ b/src/app/imex/sync/sync-safety-backup.service.spec.ts @@ -0,0 +1,367 @@ +import { TestBed, fakeAsync, tick, flush } from '@angular/core/testing'; +import { SyncSafetyBackupService, SyncSafetyBackup } from './sync-safety-backup.service'; +import { PfapiService } from '../../pfapi/pfapi.service'; + +describe('SyncSafetyBackupService', () => { + let service: SyncSafetyBackupService; + let mockPfapiService: jasmine.SpyObj; + let mockDb: jasmine.SpyObj; + let mockMetaModel: jasmine.SpyObj; + let mockEv: jasmine.SpyObj; + let eventHandlers: { [key: string]: ((...args: unknown[]) => void)[] }; + + beforeEach(() => { + eventHandlers = {}; + + mockDb = { + load: jasmine.createSpy('load').and.returnValue(Promise.resolve([])), + save: jasmine.createSpy('save').and.returnValue(Promise.resolve()), + }; + + mockMetaModel = { + load: jasmine.createSpy('load').and.returnValue( + Promise.resolve({ + lastUpdateAction: 'task', + }), + ), + }; + + mockEv = { + on: jasmine + .createSpy('on') + .and.callFake((event: string, handler: (...args: unknown[]) => void) => { + if (!eventHandlers[event]) { + eventHandlers[event] = []; + } + eventHandlers[event].push(handler); + }), + }; + + const mockPf = { + db: mockDb, + metaModel: mockMetaModel, + ev: mockEv, + loadCompleteBackup: jasmine.createSpy('loadCompleteBackup').and.returnValue( + Promise.resolve({ + project: { entities: {} }, + task: { entities: {} }, + }), + ), + }; + + mockPfapiService = jasmine.createSpyObj('PfapiService', ['importCompleteBackup'], { + pf: mockPf, + }); + + TestBed.configureTestingModule({ + providers: [ + SyncSafetyBackupService, + { provide: PfapiService, useValue: mockPfapiService }, + ], + }); + + service = TestBed.inject(SyncSafetyBackupService); + }); + + describe('lazy PfapiService injection', () => { + it('should instantiate without circular dependency errors', () => { + // The service uses Injector.get() instead of direct inject() + // which defers the resolution of PfapiService + expect(service).toBeTruthy(); + }); + + it('should defer event subscription with setTimeout', fakeAsync(() => { + // The event subscription is deferred to avoid circular dependency during construction + expect(mockEv.on).not.toHaveBeenCalled(); + + tick(0); // Process setTimeout + + expect(mockEv.on).toHaveBeenCalledWith( + 'onBeforeUpdateLocal', + jasmine.any(Function), + ); + })); + + it('should cache PfapiService after first access', fakeAsync(() => { + tick(0); // Process constructor setTimeout + + // Multiple operations should use the cached instance + service.getBackups(); + service.getBackups(); + + // Both calls use the same mocked db.load + expect(mockDb.load).toHaveBeenCalledTimes(2); + })); + }); + + describe('createBackup', () => { + beforeEach(fakeAsync(() => { + tick(0); // Process constructor setTimeout + })); + + it('should create a manual backup', async () => { + await service.createBackup(); + + expect(mockPfapiService.pf.loadCompleteBackup).toHaveBeenCalled(); + expect(mockMetaModel.load).toHaveBeenCalled(); + expect(mockDb.save).toHaveBeenCalled(); + }); + + it('should include lastUpdateAction in backup', async () => { + await service.createBackup(); + + const saveCall = mockDb.save.calls.mostRecent(); + const savedBackups = saveCall.args[1] as SyncSafetyBackup[]; + + expect(savedBackups.length).toBeGreaterThan(0); + expect(savedBackups[0].lastChangedModelId).toBe('task'); + expect(savedBackups[0].reason).toBe('MANUAL'); + }); + + it('should emit backupsChanged$ after creating backup', async () => { + let emitted = false; + service.backupsChanged$.subscribe(() => { + emitted = true; + }); + + await service.createBackup(); + + expect(emitted).toBe(true); + }); + }); + + describe('getBackups', () => { + beforeEach(fakeAsync(() => { + tick(0); // Process constructor setTimeout + })); + + it('should return empty array when no backups exist', async () => { + mockDb.load.and.returnValue(Promise.resolve(null)); + + const backups = await service.getBackups(); + + expect(backups).toEqual([]); + }); + + it('should return empty array when db returns non-array', async () => { + mockDb.load.and.returnValue(Promise.resolve({ invalid: 'data' })); + + const backups = await service.getBackups(); + + expect(backups).toEqual([]); + }); + + it('should filter out invalid backups', async () => { + mockDb.load.and.returnValue( + Promise.resolve([ + { id: 'valid-1', timestamp: Date.now(), data: {}, reason: 'MANUAL' }, + { id: '', timestamp: Date.now(), data: {}, reason: 'MANUAL' }, // invalid - empty id + { id: 'EMPTY', timestamp: Date.now(), data: {}, reason: 'MANUAL' }, // invalid - EMPTY id + null, // invalid - null + { timestamp: Date.now() }, // invalid - no id + ]), + ); + + const backups = await service.getBackups(); + + expect(backups.length).toBe(1); + expect(backups[0].id).toBe('valid-1'); + }); + + it('should sort backups by timestamp descending', async () => { + const now = Date.now(); + mockDb.load.and.returnValue( + Promise.resolve([ + { id: 'old', timestamp: now - 10000, data: {}, reason: 'MANUAL' }, + { id: 'newest', timestamp: now, data: {}, reason: 'MANUAL' }, + { id: 'middle', timestamp: now - 5000, data: {}, reason: 'MANUAL' }, + ]), + ); + + const backups = await service.getBackups(); + + expect(backups[0].id).toBe('newest'); + expect(backups[1].id).toBe('middle'); + expect(backups[2].id).toBe('old'); + }); + + it('should regenerate duplicate IDs', async () => { + mockDb.load.and.returnValue( + Promise.resolve([ + { id: 'duplicate', timestamp: Date.now(), data: {}, reason: 'MANUAL' }, + { id: 'duplicate', timestamp: Date.now() - 1000, data: {}, reason: 'MANUAL' }, + ]), + ); + + const backups = await service.getBackups(); + + // Both backups should be returned with unique IDs + expect(backups.length).toBe(2); + expect(backups[0].id).not.toBe(backups[1].id); + }); + + it('should return empty array on load error', async () => { + mockDb.load.and.returnValue(Promise.reject(new Error('Load failed'))); + + const backups = await service.getBackups(); + + expect(backups).toEqual([]); + }); + }); + + describe('deleteBackup', () => { + beforeEach(fakeAsync(() => { + tick(0); // Process constructor setTimeout + })); + + it('should remove backup by id', async () => { + const existingBackups: SyncSafetyBackup[] = [ + { id: 'backup-1', timestamp: Date.now(), data: {} as any, reason: 'MANUAL' }, + { + id: 'backup-2', + timestamp: Date.now() - 1000, + data: {} as any, + reason: 'MANUAL', + }, + ]; + mockDb.load.and.returnValue(Promise.resolve(existingBackups)); + + await service.deleteBackup('backup-1'); + + const saveCall = mockDb.save.calls.mostRecent(); + const savedBackups = saveCall.args[1] as SyncSafetyBackup[]; + + expect(savedBackups.length).toBe(1); + expect(savedBackups[0].id).toBe('backup-2'); + }); + + it('should emit backupsChanged$ after deleting', async () => { + mockDb.load.and.returnValue(Promise.resolve([])); + + let emitted = false; + service.backupsChanged$.subscribe(() => { + emitted = true; + }); + + await service.deleteBackup('any-id'); + + expect(emitted).toBe(true); + }); + }); + + describe('clearAllBackups', () => { + beforeEach(fakeAsync(() => { + tick(0); // Process constructor setTimeout + })); + + it('should save empty array', async () => { + await service.clearAllBackups(); + + expect(mockDb.save).toHaveBeenCalledWith('SYNC_SAFETY_BACKUPS', [], true); + }); + + it('should emit backupsChanged$', async () => { + let emitted = false; + service.backupsChanged$.subscribe(() => { + emitted = true; + }); + + await service.clearAllBackups(); + + expect(emitted).toBe(true); + }); + }); + + describe('restoreBackup', () => { + beforeEach(fakeAsync(() => { + tick(0); // Process constructor setTimeout + })); + + it('should throw error when backup not found', async () => { + mockDb.load.and.returnValue(Promise.resolve([])); + + await expectAsync(service.restoreBackup('non-existent')).toBeRejectedWithError( + 'Backup with ID non-existent not found', + ); + }); + + it('should not restore when user cancels confirmation', async () => { + const backupData = { project: {} }; + mockDb.load.and.returnValue( + Promise.resolve([ + { id: 'backup-1', timestamp: Date.now(), data: backupData, reason: 'MANUAL' }, + ]), + ); + + spyOn(window, 'confirm').and.returnValue(false); + + await service.restoreBackup('backup-1'); + + expect(mockPfapiService.importCompleteBackup).not.toHaveBeenCalled(); + }); + + it('should restore when user confirms', async () => { + const backupData = { project: {}, task: {} }; + mockDb.load.and.returnValue( + Promise.resolve([ + { id: 'backup-1', timestamp: Date.now(), data: backupData, reason: 'MANUAL' }, + ]), + ); + + spyOn(window, 'confirm').and.returnValue(true); + + await service.restoreBackup('backup-1'); + + expect(mockPfapiService.importCompleteBackup).toHaveBeenCalledWith( + backupData, + false, // isSkipLegacyWarnings + true, // isSkipReload + true, // isForceConflict + ); + }); + + it('should throw error when restore fails', async () => { + mockDb.load.and.returnValue( + Promise.resolve([ + { id: 'backup-1', timestamp: Date.now(), data: {}, reason: 'MANUAL' }, + ]), + ); + + spyOn(window, 'confirm').and.returnValue(true); + mockPfapiService.importCompleteBackup.and.returnValue( + Promise.reject(new Error('Import failed')), + ); + + await expectAsync(service.restoreBackup('backup-1')).toBeRejectedWithError( + 'Failed to restore backup: Error: Import failed', + ); + }); + }); + + describe('onBeforeUpdateLocal event handler', () => { + beforeEach(fakeAsync(() => { + tick(0); // Process constructor setTimeout + })); + + it('should create backup when event is received', fakeAsync(async () => { + const eventData = { + backup: { project: {}, task: {} }, + modelsToUpdate: ['task', 'project'], + }; + + // Trigger the event + const handler = eventHandlers['onBeforeUpdateLocal'][0]; + await handler(eventData); + flush(); + + expect(mockDb.save).toHaveBeenCalled(); + + const saveCall = mockDb.save.calls.mostRecent(); + const savedBackups = saveCall.args[1] as SyncSafetyBackup[]; + + expect(savedBackups.length).toBeGreaterThan(0); + expect(savedBackups[0].reason).toBe('BEFORE_UPDATE_LOCAL'); + expect(savedBackups[0].modelsToUpdate).toEqual(['task', 'project']); + })); + }); +});