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.
This commit is contained in:
Johannes Millan 2025-12-15 13:28:58 +01:00
parent ef92a12667
commit 082aca26e8
2 changed files with 369 additions and 2 deletions

View file

@ -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

View file

@ -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<any>;
let mockDb: jasmine.SpyObj<any>;
let mockMetaModel: jasmine.SpyObj<any>;
let mockEv: jasmine.SpyObj<any>;
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']);
}));
});
});