refactor: integrate pfapi into oplog 2

This commit is contained in:
Johannes Millan 2026-01-06 22:33:30 +01:00
parent 1f8fe61c84
commit db990b7018
232 changed files with 4941 additions and 9613 deletions

View file

@ -0,0 +1,144 @@
# PFAPI Elimination - Current Status
## Goal
Delete the entire `src/app/pfapi/` directory (~83 files, 2.0 MB) by moving necessary code into sync/ or core/, removing the legacy abstraction layer.
## Completed Phases
### Phase 1: Delete Dead Code ✅
- Deleted empty migration system
- Deleted custom Observable/Event system
- Deleted PFAPI migration service
### Phase 2: Refactor ClientIdService ✅
- Modified `src/app/core/util/client-id.service.ts` to use direct IndexedDB
- Removed PfapiService dependency
### Phase 3: Move Sync Providers ✅
- Created `src/app/sync/providers/` structure
- Moved SuperSync, Dropbox, WebDAV, LocalFile providers
- Moved encryption/compression utilities
- Created `sync-exports.ts` barrel for backward compatibility
### Phase 4: Transform PfapiService → SyncService ✅
- Renamed to `src/app/sync/sync.service.ts`
- Added direct methods to replace `pf.*` accessors:
- `sync()`, `clearDatabase()`, `loadGlobalConfig()`
- `getSyncProviderById()`, `setPrivateCfgForSyncProvider()`
- `forceUploadLocalState()`, `forceDownloadRemoteState()`
- `isSyncInProgress` getter
### Phase 5: Move Validation & Config ✅
- Moved validation/repair files to `src/app/sync/validation/`
- Moved model config to `src/app/sync/model-config.ts`
- Moved types to `src/app/sync/sync.types.ts`
### Phase 6: Delete PFAPI Core ✅
- Deleted entire `src/app/pfapi/` directory
- Fixed task-archive.service.ts to use ArchiveDbAdapter
- Fixed time-tracking.service.ts to use ArchiveDbAdapter
- Fixed user-profile.service.ts
- Fixed file-imex.component.ts
## Phase 7: In Progress - Fix Remaining `pf.*` References
### Files Still Needing Fixes
These files still have `pf.*` references that need to be replaced with direct service methods:
1. **`src/app/imex/sync/sync-wrapper.service.ts`**
- `pf.metaModel.setVectorClockFromBridge()`
- `pf.metaModel.load()`
- `pf.ev.emit('syncStatusChange')`
2. **`src/app/imex/sync/sync-config.service.ts`**
- `pf.getSyncProviderById()`
- `pf.getActiveSyncProvider()`
3. **`src/app/imex/sync/sync-safety-backup.service.ts`**
- Multiple `pf.*` calls
4. **`src/app/imex/sync/dropbox/store/dropbox.effects.ts`**
- `currentProviderPrivateCfg$` observable type issues
5. **`src/app/imex/sync/super-sync-restore.service.ts`**
- `pf.getActiveSyncProvider()`
6. **`src/app/imex/sync/encryption-password-change.service.ts`**
- `pf.getActiveSyncProvider()`
7. **Op-log files** (various `pf.*` references)
- `operation-log-hydrator.service.ts`
- Others in `src/app/op-log/`
### Missing Error Exports
Add to `src/app/sync/sync-exports.ts`:
- `CanNotMigrateMajorDownError`
- `LockPresentError`
- `NoRemoteModelFile`
- `PotentialCorsError`
- `RevMismatchForModelError`
- `SyncInvalidTimeValuesError`
### Type Issues
- `currentProviderPrivateCfg$` observable returns `{}` type instead of proper provider config type
- Need to fix typing in `sync.service.ts` or create proper type union
## Next Steps
1. Run `ng build --no-watch --configuration=development` to get current error list
2. Fix each file's `pf.*` references by:
- Using existing PfapiService methods where available
- Adding new methods to PfapiService if needed
- For `pf.ev.emit()` calls, use RxJS Subject emissions
3. Add missing error class exports to `sync-exports.ts`
4. Fix type issues with observable returns
5. Run full test suite: `npm test`, `npm run e2e:supersync`, `npm run e2e:webdav`
## Key Design Decisions
1. **No backward compat for old PFAPI format** - Users on old format need fresh sync
2. **Preserve OAuth tokens** - Use SAME DB name (`pf`) and key format (`PRIVATE_CFG__<id>`)
3. **Preserve client ID** - Use SAME DB name (`pf`) and key (`CLIENT_ID`)
4. **Keep legacy PBKDF2 decryption** - For reading old encrypted data
5. **Use ArchiveDbAdapter** - For direct archive persistence (not through pfapiService.m)
## Files Already Fixed
- `src/app/features/time-tracking/task-archive.service.ts` - Uses ArchiveDbAdapter
- `src/app/features/time-tracking/time-tracking.service.ts` - Uses ArchiveDbAdapter
- `src/app/features/user-profile/user-profile.service.ts` - Direct service methods
- `src/app/imex/file-imex/file-imex.component.ts` - loadCompleteBackup(true)
- `src/app/imex/local-backup/local-backup.service.ts` - getAllSyncModelDataFromStore()
## Commands to Run
```bash
# Check current build errors
ng build --no-watch --configuration=development
# Check individual file
npm run checkFile <filepath>
# Run tests
npm test
npm run e2e:supersync
npm run e2e:webdav
npm run e2e
```

View file

@ -8,7 +8,7 @@ import { JiraCfg } from '../src/app/features/issue/providers/jira/jira.model';
import { AppDataCompleteLegacy, SyncGetRevResult } from '../src/app/imex/sync/sync.model';
import { Task } from '../src/app/features/tasks/task.model';
import { LocalBackupMeta } from '../src/app/imex/local-backup/local-backup.model';
import { AppDataCompleteNew } from '../src/app/pfapi/pfapi-config';
import { AppDataComplete } from '../src/app/sync/model-config';
import {
PluginNodeScriptRequest,
PluginNodeScriptResult,
@ -140,7 +140,7 @@ export interface ElectronAPI {
jiraSetupImgHeaders(args: { jiraCfg: JiraCfg }): void;
backupAppData(appData: AppDataCompleteLegacy | AppDataCompleteNew): void;
backupAppData(appData: AppDataCompleteLegacy | AppDataComplete): void;
updateCurrentTask(
task: Task | null,

View file

@ -33,7 +33,7 @@ import { LongPressDirective } from '../../ui/longpress/longpress.directive';
import { isOnline$ } from '../../util/is-online';
import { Store } from '@ngrx/store';
import { showFocusOverlay } from '../../features/focus-mode/store/focus-mode.actions';
import { SyncStatus } from '../../pfapi/api';
import { SyncStatus } from '../../sync/sync-exports';
import { PluginHeaderBtnsComponent } from '../../plugins/ui/plugin-header-btns.component';
import { PluginSidePanelBtnsComponent } from '../../plugins/ui/plugin-side-panel-btns.component';
import { PageTitleComponent } from './page-title/page-title.component';

View file

@ -3,14 +3,12 @@ import { from, Observable } from 'rxjs';
import { mapTo, take } from 'rxjs/operators';
import { Store } from '@ngrx/store';
import { allDataWasLoaded } from '../../root-store/meta/all-data-was-loaded.actions';
import { PfapiService } from '../../pfapi/pfapi.service';
import { DataInitStateService } from './data-init-state.service';
import { UserProfileService } from '../../features/user-profile/user-profile.service';
import { OperationLogHydratorService } from '../../op-log/store/operation-log-hydrator.service';
@Injectable({ providedIn: 'root' })
export class DataInitService {
private _pfapiService = inject(PfapiService);
private _store$ = inject<Store<any>>(Store);
private _dataInitStateService = inject(DataInitStateService);
private _userProfileService = inject(UserProfileService);
@ -43,9 +41,6 @@ export class DataInitService {
await this._userProfileService.initialize();
}
// Ensure legacy migration check is done (if applicable)
await this._pfapiService.pf.wasDataMigratedInitiallyPromise;
// Hydrate from Operation Log (which handles migration from legacy if needed)
await this._operationLogHydratorService.hydrateStore();
}

View file

@ -2,10 +2,10 @@ import { inject, Injectable } from '@angular/core';
import { AppDataCompleteLegacy } from '../../imex/sync/sync.model';
import { T } from '../../t.const';
import { TranslateService } from '@ngx-translate/core';
import { isDataRepairPossible } from '../../pfapi/repair/is-data-repair-possible.util';
import { getLastValidityError } from '../../pfapi/validate/is-related-model-data-valid';
import { isDataRepairPossible } from '../../sync/validation/is-data-repair-possible.util';
import { getLastValidityError } from '../../sync/validation/is-related-model-data-valid';
import { IS_ELECTRON } from '../../app.constants';
import { AppDataCompleteNew } from '../../pfapi/pfapi-config';
import { AppDataComplete } from '../../sync/model-config';
import { Log } from '../log';
@Injectable({
@ -14,9 +14,7 @@ import { Log } from '../log';
export class DataRepairService {
private _translateService = inject(TranslateService);
isRepairPossibleAndConfirmed(
dataIn: AppDataCompleteLegacy | AppDataCompleteNew,
): boolean {
isRepairPossibleAndConfirmed(dataIn: AppDataCompleteLegacy | AppDataComplete): boolean {
if (!isDataRepairPossible(dataIn)) {
Log.log({ dataIn });
alert('Data damaged, repair not possible.');

View file

@ -9,8 +9,8 @@ import {
} from './global-error-handler.util';
import { saveBeforeLastErrorActionLog } from '../../util/action-logger';
import { error } from 'electron-log/renderer';
import { PfapiService } from '../../pfapi/pfapi.service';
import { CompleteBackup } from '../../pfapi/api';
import { BackupService } from '../../sync/backup.service';
import { CompleteBackup } from '../../sync/sync-exports';
import { Log } from '../log';
let isErrorAlertShown = false;
@ -80,7 +80,7 @@ export class GlobalErrorHandler implements ErrorHandler {
private async _getUserData(): Promise<CompleteBackup<any> | undefined> {
try {
return await this.injector.get(PfapiService).pf.loadCompleteBackup(true);
return await this.injector.get(BackupService).loadCompleteBackup(true);
} catch (e) {
Log.err('Cannot load user data for error modal');
Log.err(e);

View file

@ -1,52 +1,15 @@
import { Injectable } from '@angular/core';
import { DBSchema, IDBPDatabase, openDB } from 'idb';
import { inject, Injectable } from '@angular/core';
import { ArchiveModel } from '../../features/time-tracking/time-tracking.model';
import { PFLog } from '../log';
import { OperationLogStoreService } from '../../op-log/store/operation-log-store.service';
/**
* Database key constants for archive storage.
*/
const DB_KEY_ARCHIVE_YOUNG = 'archiveYoung' as const;
const DB_KEY_ARCHIVE_OLD = 'archiveOld' as const;
/**
* Database configuration matching PFAPI's IndexedDbAdapter.
* The 'pf' database with 'main' object store is shared with PFAPI.
*/
const DB_NAME = 'pf';
const DB_MAIN_NAME = 'main';
const DB_VERSION = 1;
/**
* Minimal schema for the PFAPI database.
* We only access archive keys, but the database may contain other data.
*/
interface PfapiDb extends DBSchema {
[DB_MAIN_NAME]: {
key: string;
value: unknown;
};
}
/**
* Low-level IndexedDB adapter for archive storage.
* Adapter for archive storage operations.
*
* ## Purpose
*
* This service provides direct IndexedDB access to archive data (archiveYoung, archiveOld)
* WITHOUT going through PfapiService. This breaks the circular dependency:
*
* ```
* DataInitService OperationLogHydratorService OperationApplierService
* ArchiveOperationHandler [THIS SERVICE instead of PfapiService]
* ```
*
* ## Database Sharing
*
* This service opens a connection to the same 'pf' database that PFAPI uses.
* IndexedDB supports multiple connections to the same database, and since we
* only use this for archive operations that specify `isIgnoreDBLock: true`,
* there's no conflict with PFAPI's lock mechanism.
* This service provides a clean interface for archive persistence operations
* (archiveYoung, archiveOld). It delegates to OperationLogStoreService which
* stores archives in the SUP_OPS IndexedDB database.
*
* ## Usage
*
@ -60,83 +23,33 @@ interface PfapiDb extends DBSchema {
providedIn: 'root',
})
export class ArchiveDbAdapter {
private _db?: IDBPDatabase<PfapiDb>;
private _initPromise?: Promise<void>;
private _opLogStore = inject(OperationLogStoreService);
/**
* Initializes the database connection.
* Safe to call multiple times - subsequent calls return the same promise.
*/
async init(): Promise<void> {
if (this._initPromise) {
return this._initPromise;
}
this._initPromise = this._doInit();
return this._initPromise;
}
private async _doInit(): Promise<void> {
try {
// Open connection to existing PFAPI database
// Note: We don't create stores here - they're created by PFAPI
this._db = await openDB<PfapiDb>(DB_NAME, DB_VERSION, {
// No upgrade needed - PFAPI handles schema creation
// If this is called before PFAPI, the database won't have the store yet
// but that's fine because ArchiveOperationHandler is only called after data init
});
PFLog.normal('[ArchiveDbAdapter] Database connection initialized');
} catch (e) {
PFLog.err('[ArchiveDbAdapter] Failed to initialize database', e);
this._initPromise = undefined; // Allow retry
throw e;
}
}
/**
* Ensures the database is initialized before use.
*/
private async _ensureDb(): Promise<IDBPDatabase<PfapiDb>> {
if (!this._db) {
await this.init();
}
if (!this._db) {
throw new Error('[ArchiveDbAdapter] Database not initialized');
}
return this._db;
}
/**
* Loads archiveYoung data from IndexedDB.
* Loads archiveYoung data from SUP_OPS IndexedDB.
*/
async loadArchiveYoung(): Promise<ArchiveModel | undefined> {
const db = await this._ensureDb();
const data = await db.get(DB_MAIN_NAME, DB_KEY_ARCHIVE_YOUNG);
return data as ArchiveModel | undefined;
return this._opLogStore.loadArchiveYoung();
}
/**
* Saves archiveYoung data to IndexedDB.
* Saves archiveYoung data to SUP_OPS IndexedDB.
*/
async saveArchiveYoung(data: ArchiveModel): Promise<void> {
const db = await this._ensureDb();
await db.put(DB_MAIN_NAME, data, DB_KEY_ARCHIVE_YOUNG);
return this._opLogStore.saveArchiveYoung(data);
}
/**
* Loads archiveOld data from IndexedDB.
* Loads archiveOld data from SUP_OPS IndexedDB.
*/
async loadArchiveOld(): Promise<ArchiveModel | undefined> {
const db = await this._ensureDb();
const data = await db.get(DB_MAIN_NAME, DB_KEY_ARCHIVE_OLD);
return data as ArchiveModel | undefined;
return this._opLogStore.loadArchiveOld();
}
/**
* Saves archiveOld data to IndexedDB.
* Saves archiveOld data to SUP_OPS IndexedDB.
*/
async saveArchiveOld(data: ArchiveModel): Promise<void> {
const db = await this._ensureDb();
await db.put(DB_MAIN_NAME, data, DB_KEY_ARCHIVE_OLD);
return this._opLogStore.saveArchiveOld(data);
}
}

View file

@ -0,0 +1,411 @@
import { TestBed } from '@angular/core/testing';
import { LegacyPfDbService } from './legacy-pf-db.service';
import * as idb from 'idb';
describe('LegacyPfDbService', () => {
let service: LegacyPfDbService;
let mockDb: {
get: jasmine.Spy;
put: jasmine.Spy;
delete: jasmine.Spy;
getAllKeys: jasmine.Spy;
transaction: jasmine.Spy;
close: jasmine.Spy;
};
beforeEach(() => {
mockDb = {
get: jasmine.createSpy('get'),
put: jasmine.createSpy('put'),
delete: jasmine.createSpy('delete'),
getAllKeys: jasmine.createSpy('getAllKeys'),
transaction: jasmine.createSpy('transaction'),
close: jasmine.createSpy('close'),
};
spyOn(idb, 'openDB').and.resolveTo(mockDb as unknown as idb.IDBPDatabase);
TestBed.configureTestingModule({
providers: [LegacyPfDbService],
});
service = TestBed.inject(LegacyPfDbService);
});
describe('load', () => {
it('should load data from the database by key', async () => {
const mockData = { id: 'test', value: 'data' };
mockDb.get.and.resolveTo(mockData);
const result = await service.load('testKey');
expect(idb.openDB).toHaveBeenCalledWith('pf', 1, jasmine.any(Object));
expect(mockDb.get).toHaveBeenCalledWith('main', 'testKey');
expect(mockDb.close).toHaveBeenCalled();
expect(result).toEqual(mockData);
});
it('should return null if key does not exist', async () => {
mockDb.get.and.resolveTo(undefined);
const result = await service.load('nonExistentKey');
expect(result).toBeNull();
});
it('should return null on error', async () => {
mockDb.get.and.rejectWith(new Error('DB error'));
const result = await service.load('testKey');
expect(result).toBeNull();
});
});
describe('save', () => {
it('should save data to the database', async () => {
const mockData = { id: 'test', value: 'data' };
mockDb.put.and.resolveTo(undefined);
await service.save('testKey', mockData);
expect(mockDb.put).toHaveBeenCalledWith('main', mockData, 'testKey');
expect(mockDb.close).toHaveBeenCalled();
});
it('should not throw on error', async () => {
mockDb.put.and.rejectWith(new Error('DB error'));
await expectAsync(service.save('testKey', {})).toBeResolved();
});
});
describe('hasUsableEntityData', () => {
it('should return true if task data exists with non-empty ids', async () => {
mockDb.get.and.callFake((_store: string, key: string) => {
if (key === 'task') return Promise.resolve({ ids: ['t1', 't2'], entities: {} });
if (key === 'project') return Promise.resolve({ ids: [], entities: {} });
if (key === 'globalConfig') return Promise.resolve(null);
return Promise.resolve(null);
});
const result = await service.hasUsableEntityData();
expect(result).toBe(true);
});
it('should return true if project data exists with non-empty ids', async () => {
mockDb.get.and.callFake((_store: string, key: string) => {
if (key === 'task') return Promise.resolve({ ids: [], entities: {} });
if (key === 'project') return Promise.resolve({ ids: ['p1'], entities: {} });
if (key === 'globalConfig') return Promise.resolve(null);
return Promise.resolve(null);
});
const result = await service.hasUsableEntityData();
expect(result).toBe(true);
});
it('should return true if globalConfig exists', async () => {
mockDb.get.and.callFake((_store: string, key: string) => {
if (key === 'task') return Promise.resolve(null);
if (key === 'project') return Promise.resolve(null);
if (key === 'globalConfig')
return Promise.resolve({ misc: { isDarkMode: true } });
return Promise.resolve(null);
});
const result = await service.hasUsableEntityData();
expect(result).toBe(true);
});
it('should return false if no usable data exists', async () => {
mockDb.get.and.callFake((_store: string, key: string) => {
if (key === 'task') return Promise.resolve({ ids: [], entities: {} });
if (key === 'project') return Promise.resolve(null);
if (key === 'globalConfig') return Promise.resolve(null);
return Promise.resolve(null);
});
const result = await service.hasUsableEntityData();
expect(result).toBe(false);
});
it('should return false on error', async () => {
(idb.openDB as jasmine.Spy).and.rejectWith(new Error('DB error'));
const result = await service.hasUsableEntityData();
expect(result).toBe(false);
});
});
describe('loadAllEntityData', () => {
it('should load all model keys from database', async () => {
mockDb.get.and.callFake((_store: string, key: string) => {
if (key === 'task')
return Promise.resolve({ ids: ['t1'], entities: { t1: { id: 't1' } } });
if (key === 'project')
return Promise.resolve({ ids: ['p1'], entities: { p1: { id: 'p1' } } });
return Promise.resolve(null);
});
const result = await service.loadAllEntityData();
// Check that data was loaded correctly
expect((result.task as any).ids).toEqual(['t1']);
expect((result.project as any).ids).toEqual(['p1']);
expect(mockDb.close).toHaveBeenCalled();
});
it('should throw on error', async () => {
(idb.openDB as jasmine.Spy).and.rejectWith(new Error('DB error'));
await expectAsync(service.loadAllEntityData()).toBeRejectedWithError('DB error');
});
});
describe('loadMetaModel', () => {
it('should load META_MODEL from database', async () => {
const mockMeta = { vectorClock: { client1: 5 }, lastUpdate: Date.now() };
mockDb.get.and.resolveTo(mockMeta);
const result = await service.loadMetaModel();
expect(mockDb.get).toHaveBeenCalledWith('main', 'META_MODEL');
expect(result).toEqual(mockMeta);
});
it('should return empty object if META_MODEL does not exist', async () => {
mockDb.get.and.resolveTo(undefined);
const result = await service.loadMetaModel();
expect(result).toEqual({});
});
it('should return empty object on error', async () => {
mockDb.get.and.rejectWith(new Error('DB error'));
const result = await service.loadMetaModel();
expect(result).toEqual({});
});
});
describe('loadClientId', () => {
it('should load CLIENT_ID from database', async () => {
mockDb.get.and.resolveTo('client-123');
const result = await service.loadClientId();
expect(mockDb.get).toHaveBeenCalledWith('main', 'CLIENT_ID');
expect(result).toBe('client-123');
});
it('should return null if CLIENT_ID does not exist', async () => {
mockDb.get.and.resolveTo(undefined);
const result = await service.loadClientId();
expect(result).toBeNull();
});
it('should return null on error', async () => {
mockDb.get.and.rejectWith(new Error('DB error'));
const result = await service.loadClientId();
expect(result).toBeNull();
});
});
describe('loadArchiveYoung / loadArchiveOld', () => {
it('should load archiveYoung from database', async () => {
const mockArchive = {
task: { ids: ['t1'], entities: { t1: { id: 't1' } } },
timeTracking: { ids: [], entities: {} },
lastTimeTrackingFlush: 123,
};
mockDb.get.and.resolveTo(mockArchive);
const result = await service.loadArchiveYoung();
expect(mockDb.get).toHaveBeenCalledWith('main', 'archiveYoung');
expect(result.task.ids).toEqual(['t1']);
expect(result.lastTimeTrackingFlush).toBe(123);
});
it('should return default archive if archiveYoung does not exist', async () => {
mockDb.get.and.resolveTo(undefined);
const result = await service.loadArchiveYoung();
expect(result.task).toEqual({ ids: [], entities: {} });
expect(result.lastTimeTrackingFlush).toBe(0);
});
it('should load archiveOld from database', async () => {
const mockArchive = {
task: { ids: ['t2'], entities: { t2: { id: 't2' } } },
timeTracking: { ids: [], entities: {} },
lastTimeTrackingFlush: 456,
};
mockDb.get.and.resolveTo(mockArchive);
const result = await service.loadArchiveOld();
expect(mockDb.get).toHaveBeenCalledWith('main', 'archiveOld');
expect(result.task.ids).toEqual(['t2']);
expect(result.lastTimeTrackingFlush).toBe(456);
});
});
describe('acquireMigrationLock / releaseMigrationLock', () => {
it('should acquire lock when no existing lock', async () => {
mockDb.get.and.resolveTo(undefined);
mockDb.put.and.resolveTo(undefined);
const result = await service.acquireMigrationLock();
expect(result).toBe(true);
expect(mockDb.put).toHaveBeenCalledWith(
'main',
jasmine.objectContaining({
timestamp: jasmine.any(Number),
tabId: jasmine.any(String),
}),
'_migration_lock',
);
});
it('should not acquire lock when another tab holds valid lock', async () => {
mockDb.get.and.resolveTo({
timestamp: Date.now(), // Recent timestamp
tabId: 'other-tab-id',
});
const result = await service.acquireMigrationLock();
expect(result).toBe(false);
expect(mockDb.put).not.toHaveBeenCalled();
});
it('should acquire lock when existing lock is expired', async () => {
mockDb.get.and.resolveTo({
timestamp: Date.now() - 120000, // 2 minutes ago (expired)
tabId: 'other-tab-id',
});
mockDb.put.and.resolveTo(undefined);
const result = await service.acquireMigrationLock();
expect(result).toBe(true);
expect(mockDb.put).toHaveBeenCalled();
});
it('should release lock when tab owns it', async () => {
// First acquire the lock to set up the tabId
mockDb.get.and.resolveTo(undefined);
mockDb.put.and.resolveTo(undefined);
await service.acquireMigrationLock();
// Now mock the get to return a lock with this service's tabId
const tabId = (service as any)._tabId;
mockDb.get.and.resolveTo({
timestamp: Date.now(),
tabId,
});
mockDb.delete.and.resolveTo(undefined);
await service.releaseMigrationLock();
expect(mockDb.delete).toHaveBeenCalledWith('main', '_migration_lock');
});
it('should not release lock when another tab owns it', async () => {
mockDb.get.and.resolveTo({
timestamp: Date.now(),
tabId: 'other-tab-id',
});
mockDb.delete.and.resolveTo(undefined);
await service.releaseMigrationLock();
expect(mockDb.delete).not.toHaveBeenCalled();
});
});
describe('clearAll', () => {
it('should clear all data from the database', async () => {
const mockStore = {
clear: jasmine.createSpy('clear').and.resolveTo(undefined),
};
const mockTx = {
store: mockStore,
done: Promise.resolve(),
};
mockDb.transaction.and.returnValue(mockTx);
await service.clearAll();
expect(mockDb.transaction).toHaveBeenCalledWith('main', 'readwrite');
expect(mockStore.clear).toHaveBeenCalled();
expect(mockDb.close).toHaveBeenCalled();
});
it('should not throw on error', async () => {
mockDb.transaction.and.throwError(new Error('DB error'));
await expectAsync(service.clearAll()).toBeResolved();
});
});
describe('saveArchive', () => {
it('should save archive to the database', async () => {
const mockArchive = {
task: { ids: ['t1'], entities: { t1: { id: 't1' } } },
timeTracking: {},
lastTimeTrackingFlush: 123,
};
mockDb.put.and.resolveTo(undefined);
await service.saveArchive('archiveYoung', mockArchive as any);
expect(mockDb.put).toHaveBeenCalledWith('main', mockArchive, 'archiveYoung');
});
});
describe('saveMetaModel', () => {
it('should merge with existing meta model', async () => {
const existingMeta = { vectorClock: { client1: 3 }, lastUpdate: 1000 };
const newMeta = { lastUpdate: 2000, lastUpdateAction: 'task.update' };
mockDb.get.and.resolveTo(existingMeta);
mockDb.put.and.resolveTo(undefined);
await service.saveMetaModel(newMeta);
expect(mockDb.put).toHaveBeenCalledWith(
'main',
{
vectorClock: { client1: 3 },
lastUpdate: 2000,
lastUpdateAction: 'task.update',
},
'META_MODEL',
);
});
it('should create new meta model if none exists', async () => {
mockDb.get.and.resolveTo(undefined);
mockDb.put.and.resolveTo(undefined);
const newMeta = { vectorClock: { client1: 1 } };
await service.saveMetaModel(newMeta);
expect(mockDb.put).toHaveBeenCalledWith('main', newMeta, 'META_MODEL');
});
});
});

View file

@ -0,0 +1,370 @@
import { Injectable } from '@angular/core';
import { openDB, IDBPDatabase } from 'idb';
import { VectorClock } from '../util/vector-clock';
import { ArchiveModel } from '../../features/time-tracking/time-tracking.model';
import { initialTimeTrackingState } from '../../features/time-tracking/store/time-tracking.reducer';
import { Log } from '../log';
/**
* Type representing all legacy app data stored in the 'pf' database.
* Used when loading data for migration.
*/
export interface LegacyAppData {
task?: unknown;
project?: unknown;
tag?: unknown;
simpleCounter?: unknown;
note?: unknown;
taskRepeatCfg?: unknown;
reminders?: unknown;
planner?: unknown;
boards?: unknown;
menuTree?: unknown;
issueProvider?: unknown;
metric?: unknown;
timeTracking?: unknown;
globalConfig?: unknown;
pluginUserData?: unknown;
pluginMetadata?: unknown;
archiveYoung?: ArchiveModel;
archiveOld?: ArchiveModel;
}
const DB_NAME = 'pf';
const DB_VERSION = 1;
const STORE_NAME = 'main';
// Migration lock key - stored in the pf database
const MIGRATION_LOCK_KEY = '_migration_lock';
const LOCK_TIMEOUT_MS = 60000; // 1 minute lock timeout
interface MigrationLock {
timestamp: number;
tabId: string;
}
interface LegacyMetaModel {
vectorClock?: VectorClock;
lastUpdate?: number;
lastUpdateAction?: string;
}
const DEFAULT_ARCHIVE: ArchiveModel = {
task: { ids: [], entities: {} },
timeTracking: initialTimeTrackingState,
lastTimeTrackingFlush: 0,
};
/**
* Model keys in the legacy pf database
*/
const MODEL_KEYS: (keyof LegacyAppData)[] = [
'task',
'project',
'tag',
'simpleCounter',
'note',
'taskRepeatCfg',
'reminders',
'planner',
'boards',
'menuTree',
'issueProvider',
'metric',
'timeTracking',
'globalConfig',
'pluginUserData',
'pluginMetadata',
'archiveYoung',
'archiveOld',
];
/**
* Centralized service for accessing the legacy `pf` IndexedDB database.
* Consolidates all scattered openDB('pf') calls into a single service.
*
* Used for:
* - Migration of legacy data to operation log
* - Archive access (archiveYoung, archiveOld)
* - Legacy reminder migration
* - Disaster recovery
*/
@Injectable({
providedIn: 'root',
})
export class LegacyPfDbService {
private _tabId = Math.random().toString(36).substring(2, 15);
/**
* Opens the legacy pf database, creating it if it doesn't exist.
*/
private async _openDb(): Promise<IDBPDatabase> {
return openDB(DB_NAME, DB_VERSION, {
upgrade: (database) => {
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME);
}
},
});
}
/**
* Loads data from the legacy pf database by key.
*/
async load<T>(key: string): Promise<T | null> {
try {
const db = await this._openDb();
const result = await db.get(STORE_NAME, key);
db.close();
return result ?? null;
} catch (e) {
Log.warn('LegacyPfDbService.load failed:', e);
return null;
}
}
/**
* Saves data to the legacy pf database.
*/
async save(key: string, data: unknown): Promise<void> {
try {
const db = await this._openDb();
await db.put(STORE_NAME, data, key);
db.close();
} catch (e) {
Log.warn('LegacyPfDbService.save failed:', e);
}
}
/**
* Checks if the legacy pf database exists and has any data.
*/
async databaseExists(): Promise<boolean> {
try {
const databases = await indexedDB.databases();
return databases.some((db) => db.name === DB_NAME);
} catch {
// indexedDB.databases() is not supported in all browsers
// Fall back to trying to open the database
try {
const db = await this._openDb();
const keys = await db.getAllKeys(STORE_NAME);
db.close();
return keys.length > 0;
} catch {
return false;
}
}
}
/**
* Checks if the legacy database has usable entity data worth migrating.
* Returns true if there are tasks, projects, or global config.
*/
async hasUsableEntityData(): Promise<boolean> {
try {
const db = await this._openDb();
// Check for meaningful data in key models
const task = await db.get(STORE_NAME, 'task');
const project = await db.get(STORE_NAME, 'project');
const globalConfig = await db.get(STORE_NAME, 'globalConfig');
db.close();
// Has usable data if any of these have content
const hasTaskData = task && Array.isArray(task.ids) && task.ids.length > 0;
const hasProjectData =
project && Array.isArray(project.ids) && project.ids.length > 0;
const hasConfigData = globalConfig && typeof globalConfig === 'object';
return hasTaskData || hasProjectData || hasConfigData;
} catch (e) {
Log.warn('LegacyPfDbService.hasUsableEntityData failed:', e);
return false;
}
}
/**
* Loads all entity data from the legacy pf database.
* Returns null for missing keys.
*/
async loadAllEntityData(): Promise<LegacyAppData> {
try {
const db = await this._openDb();
const result: LegacyAppData = {};
for (const key of MODEL_KEYS) {
const data = await db.get(STORE_NAME, key);
if (data !== undefined) {
(result as Record<string, unknown>)[key] = data;
}
}
db.close();
return result;
} catch (e) {
Log.err('LegacyPfDbService.loadAllEntityData failed:', e);
throw e;
}
}
/**
* Loads the META_MODEL from the legacy database.
* Contains vectorClock, lastUpdate, lastUpdateAction.
*/
async loadMetaModel(): Promise<LegacyMetaModel> {
try {
const db = await this._openDb();
const result = await db.get(STORE_NAME, 'META_MODEL');
db.close();
return result || {};
} catch (e) {
Log.warn('LegacyPfDbService.loadMetaModel failed:', e);
return {};
}
}
/**
* Saves the META_MODEL to the legacy database.
*/
async saveMetaModel(meta: LegacyMetaModel): Promise<void> {
try {
const db = await this._openDb();
const existing = (await db.get(STORE_NAME, 'META_MODEL')) || {};
await db.put(STORE_NAME, { ...existing, ...meta }, 'META_MODEL');
db.close();
} catch (e) {
Log.warn('LegacyPfDbService.saveMetaModel failed:', e);
}
}
/**
* Loads the CLIENT_ID from the legacy database.
* Note: CLIENT_ID is stored separately from META_MODEL.
*/
async loadClientId(): Promise<string | null> {
try {
const db = await this._openDb();
const result = await db.get(STORE_NAME, 'CLIENT_ID');
db.close();
return result ?? null;
} catch (e) {
Log.warn('LegacyPfDbService.loadClientId failed:', e);
return null;
}
}
/**
* Loads the archiveYoung from the legacy database.
*/
async loadArchiveYoung(): Promise<ArchiveModel> {
return this._loadArchive('archiveYoung');
}
/**
* Loads the archiveOld from the legacy database.
*/
async loadArchiveOld(): Promise<ArchiveModel> {
return this._loadArchive('archiveOld');
}
/**
* Saves an archive to the legacy database.
*/
async saveArchive(
key: 'archiveYoung' | 'archiveOld',
archive: ArchiveModel,
): Promise<void> {
await this.save(key, archive);
}
/**
* Acquires a migration lock to prevent concurrent migrations from multiple tabs.
* Returns true if lock was acquired, false if another tab holds the lock.
*/
async acquireMigrationLock(): Promise<boolean> {
try {
const db = await this._openDb();
// Check for existing lock
const existingLock = (await db.get(STORE_NAME, MIGRATION_LOCK_KEY)) as
| MigrationLock
| undefined;
if (existingLock) {
// Check if lock is expired
const isExpired = Date.now() - existingLock.timestamp > LOCK_TIMEOUT_MS;
if (!isExpired && existingLock.tabId !== this._tabId) {
db.close();
return false;
}
}
// Acquire lock
const lock: MigrationLock = {
timestamp: Date.now(),
tabId: this._tabId,
};
await db.put(STORE_NAME, lock, MIGRATION_LOCK_KEY);
db.close();
Log.log('LegacyPfDbService: Migration lock acquired');
return true;
} catch (e) {
Log.warn('LegacyPfDbService.acquireMigrationLock failed:', e);
return false;
}
}
/**
* Releases the migration lock.
*/
async releaseMigrationLock(): Promise<void> {
try {
const db = await this._openDb();
const existingLock = (await db.get(STORE_NAME, MIGRATION_LOCK_KEY)) as
| MigrationLock
| undefined;
// Only release if we own the lock
if (existingLock && existingLock.tabId === this._tabId) {
await db.delete(STORE_NAME, MIGRATION_LOCK_KEY);
Log.log('LegacyPfDbService: Migration lock released');
}
db.close();
} catch (e) {
Log.warn('LegacyPfDbService.releaseMigrationLock failed:', e);
}
}
/**
* Clears all data from the legacy database.
* Used when resetting the application.
*/
async clearAll(): Promise<void> {
try {
const db = await this._openDb();
const tx = db.transaction(STORE_NAME, 'readwrite');
await tx.store.clear();
await tx.done;
db.close();
Log.log('LegacyPfDbService: Database cleared');
} catch (e) {
Log.warn('LegacyPfDbService.clearAll failed:', e);
}
}
private async _loadArchive(key: 'archiveYoung' | 'archiveOld'): Promise<ArchiveModel> {
try {
const db = await this._openDb();
const archive = await db.get(STORE_NAME, key);
db.close();
return archive || DEFAULT_ARCHIVE;
} catch (e) {
Log.warn(`LegacyPfDbService._loadArchive(${key}) failed:`, e);
return DEFAULT_ARCHIVE;
}
}
}

View file

@ -1,6 +1,5 @@
import { TestBed, fakeAsync, tick, flush } from '@angular/core/testing';
import { StartupService } from './startup.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { ImexViewService } from '../../imex/imex-meta/imex-view.service';
import { TranslateService } from '@ngx-translate/core';
import { LocalBackupService } from '../../imex/local-backup/local-backup.service';
@ -21,7 +20,6 @@ import { LS } from '../persistence/storage-keys.const';
describe('StartupService', () => {
let service: StartupService;
let pfapiService: jasmine.SpyObj<PfapiService>;
let matDialog: jasmine.SpyObj<MatDialog>;
let pluginService: jasmine.SpyObj<PluginService>;
@ -36,20 +34,8 @@ describe('StartupService', () => {
);
// Create spies for all dependencies
const pfapiServiceSpy = jasmine.createSpyObj('PfapiService', [
'isCheckForStrayLocalTmpDBBackupAndImport',
]);
pfapiServiceSpy.isCheckForStrayLocalTmpDBBackupAndImport.and.returnValue(
Promise.resolve(),
);
pfapiServiceSpy.pf = {
metaModel: {
load: jasmine.createSpy().and.returnValue(Promise.resolve(null)),
},
};
const imexViewServiceSpy = jasmine.createSpyObj('ImexViewService', ['']);
const translateServiceSpy = jasmine.createSpyObj('TranslateService', ['']);
const imexViewServiceSpy = jasmine.createSpyObj('ImexViewService', ['init']);
const translateServiceSpy = jasmine.createSpyObj('TranslateService', ['instant']);
const localBackupServiceSpy = jasmine.createSpyObj('LocalBackupService', [
'askForFileStoreBackupIfAvailable',
@ -105,13 +91,12 @@ describe('StartupService', () => {
]);
const syncSafetyBackupServiceSpy = jasmine.createSpyObj('SyncSafetyBackupService', [
'',
'init',
]);
TestBed.configureTestingModule({
providers: [
StartupService,
{ provide: PfapiService, useValue: pfapiServiceSpy },
{ provide: ImexViewService, useValue: imexViewServiceSpy },
{ provide: TranslateService, useValue: translateServiceSpy },
{ provide: LocalBackupService, useValue: localBackupServiceSpy },
@ -133,7 +118,6 @@ describe('StartupService', () => {
});
service = TestBed.inject(StartupService);
pfapiService = TestBed.inject(PfapiService) as jasmine.SpyObj<PfapiService>;
matDialog = TestBed.inject(MatDialog) as jasmine.SpyObj<MatDialog>;
pluginService = TestBed.inject(PluginService) as jasmine.SpyObj<PluginService>;
});
@ -164,8 +148,6 @@ describe('StartupService', () => {
service.init();
tick(200); // Wait for single instance check
expect(pfapiService.isCheckForStrayLocalTmpDBBackupAndImport).toHaveBeenCalled();
flush();
// Restore

View file

@ -1,5 +1,4 @@
import { effect, inject, Injectable } from '@angular/core';
import { PfapiService } from '../../pfapi/pfapi.service';
import { ImexViewService } from '../../imex/imex-meta/imex-view.service';
import { TranslateService } from '@ngx-translate/core';
import { LocalBackupService } from '../../imex/local-backup/local-backup.service';
@ -16,7 +15,7 @@ import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { IS_ELECTRON } from '../../app.constants';
import { Log } from '../log';
import { T } from '../../t.const';
import { DEFAULT_META_MODEL } from '../../pfapi/api/model-ctrl/meta-model-ctrl';
import { OperationLogStoreService } from '../../op-log/store/operation-log-store.service';
import { BannerId } from '../banner/banner.model';
import { isOnline$ } from '../../util/is-online';
import { LS } from '../persistence/storage-keys.const';
@ -39,7 +38,6 @@ const DEFERRED_INIT_DELAY_MS = 1000;
providedIn: 'root',
})
export class StartupService {
private _pfapiService = inject(PfapiService);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
private _imexMetaService = inject(ImexViewService);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -55,6 +53,7 @@ export class StartupService {
private _chromeExtensionInterfaceService = inject(ChromeExtensionInterfaceService);
private _projectService = inject(ProjectService);
private _trackingReminderService = inject(TrackingReminderService);
private _opLogStore = inject(OperationLogStoreService);
constructor() {
// needs to be injected somewhere to initialize
@ -154,13 +153,11 @@ export class StartupService {
}
private async _initBackups(): Promise<void> {
// if everything is normal, check for TMP stray backup
await this._pfapiService.isCheckForStrayLocalTmpDBBackupAndImport();
// if completely fresh instance check for local backups
if (IS_ELECTRON || IS_ANDROID_WEB_VIEW) {
const meta = await this._pfapiService.pf.metaModel.load();
if (!meta || meta.lastUpdate === DEFAULT_META_MODEL.lastUpdate) {
const stateCache = await this._opLogStore.loadStateCache();
// If no state cache exists, this is a fresh instance - offer to restore from backup
if (!stateCache) {
await this._localBackupService.askForFileStoreBackupIfAvailable();
}
// trigger backup init after

View file

@ -1,26 +1,27 @@
import { Injectable, inject, Injector } from '@angular/core';
import { PfapiService } from '../../pfapi/pfapi.service';
import { Injectable } from '@angular/core';
import { openDB, IDBPDatabase } from 'idb';
import { PFLog } from '../log';
// Database constants - must match PFAPI's storage
const DB_NAME = 'pf';
const DB_STORE_NAME = 'main';
const DB_VERSION = 1;
const CLIENT_ID_KEY = '__client_id_';
/**
* Service for managing the sync client ID.
*
* Abstracts client ID access from operation-log services, breaking the
* direct dependency on PfapiService. This enables cleaner separation
* between PFAPI and Operation Log systems.
* Reads/writes directly to IndexedDB to preserve existing client IDs
* and avoid dependency on PfapiService.
*
* Currently delegates to MetaModelCtrl, but the abstraction allows
* future refactoring to use independent storage if needed.
*
* Uses lazy injection via Injector.get() to break circular dependencies.
* PfapiService is only resolved when loadClientId() is called, not at
* service creation time.
* Uses the same database name ('pf') and key ('__client_id_') as the
* legacy MetaModelCtrl to ensure backward compatibility.
*/
@Injectable({
providedIn: 'root',
})
export class ClientIdService {
private _injector = inject(Injector);
private _pfapiService: PfapiService | null = null;
private _db: IDBPDatabase | null = null;
private _cachedClientId: string | null = null;
/**
@ -36,9 +37,47 @@ export class ClientIdService {
return this._cachedClientId;
}
const pfapiService = this._getPfapiService();
this._cachedClientId = await pfapiService.pf.metaModel.loadClientId();
return this._cachedClientId;
const db = await this._getDb();
const clientId = await db.get(DB_STORE_NAME, CLIENT_ID_KEY);
if (typeof clientId !== 'string') {
return null;
}
// Validate clientId format
const isOldFormat = clientId.length >= 10;
const isNewFormat = /^[BEAI]_[a-zA-Z0-9]{4}$/.test(clientId);
if (!isOldFormat && !isNewFormat) {
PFLog.critical('ClientIdService.loadClientId() Invalid clientId loaded:', {
clientId,
length: clientId.length,
});
throw new Error(`Invalid clientId loaded: ${clientId}`);
}
this._cachedClientId = clientId;
PFLog.normal('ClientIdService.loadClientId() loaded:', { clientId });
return clientId;
}
/**
* Generates a new client ID and saves it.
*
* Format: {platform}_{4-char-base62}
* Examples: "B_a7Kx", "E_m2Pq", "A_x9Yz"
*
* @returns The newly generated client ID
*/
async generateNewClientId(): Promise<string> {
const newClientId = this._generateClientId();
const db = await this._getDb();
await db.put(DB_STORE_NAME, newClientId, CLIENT_ID_KEY);
this._cachedClientId = newClientId;
PFLog.normal('ClientIdService.generateNewClientId() generated:', { newClientId });
return newClientId;
}
/**
@ -50,10 +89,74 @@ export class ClientIdService {
this._cachedClientId = null;
}
private _getPfapiService(): PfapiService {
if (!this._pfapiService) {
this._pfapiService = this._injector.get(PfapiService);
/**
* Gets or opens the IndexedDB database.
*/
private async _getDb(): Promise<IDBPDatabase> {
if (this._db) {
return this._db;
}
return this._pfapiService;
this._db = await openDB(DB_NAME, DB_VERSION, {
upgrade: (db) => {
if (!db.objectStoreNames.contains(DB_STORE_NAME)) {
db.createObjectStore(DB_STORE_NAME);
}
},
});
return this._db;
}
/**
* Generates a compact 6-char client ID.
* Format: {platform}_{4-char-base62-random}
*/
private _generateClientId(): string {
const prefix = this._getEnvironmentId();
const randomPart = this._generateBase62(4);
return `${prefix}_${randomPart}`;
}
/**
* Returns a single-character platform identifier for compact client IDs.
* B = Browser, E = Electron, A = Android, I = iOS
*/
private _getEnvironmentId(): string {
// Detect Electron
const isElectron =
typeof process !== 'undefined' && (process as any).versions?.electron;
if (isElectron) {
return 'E';
}
// Detect Android WebView
if (/Android/.test(navigator.userAgent) && /wv/.test(navigator.userAgent)) {
return 'A';
}
// Detect iOS
if (
navigator.userAgent.includes('iOS') ||
navigator.userAgent.includes('iPhone') ||
navigator.userAgent.includes('iPad')
) {
return 'I';
}
// Default: Browser
return 'B';
}
/**
* Generates a random base62 string of the specified length.
*/
private _generateBase62(length: number): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
}

View file

@ -4,7 +4,6 @@ import { Store } from '@ngrx/store';
import {
distinctUntilChanged,
filter,
first,
map,
pairwise,
startWith,
@ -14,10 +13,7 @@ import {
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { androidInterface } from '../android-interface';
import { TaskService } from '../../tasks/task.service';
import {
selectCurrentTask,
selectTaskFeatureState,
} from '../../tasks/store/task.selectors';
import { selectCurrentTask } from '../../tasks/store/task.selectors';
import { DroidLog } from '../../../core/log';
import { DateService } from '../../../core/date/date.service';
import { Task } from '../../tasks/task.model';
@ -25,10 +21,8 @@ import { selectTimer } from '../../focus-mode/store/focus-mode.selectors';
import { combineLatest, firstValueFrom } from 'rxjs';
import { HydrationStateService } from '../../../op-log/apply/hydration-state.service';
import { SnackService } from '../../../core/snack/snack.service';
import { PfapiService } from '../../../pfapi/pfapi.service';
import { selectTimeTrackingState } from '../../time-tracking/store/time-tracking.selectors';
import { environment } from '../../../../environments/environment';
import { GlobalTrackingIntervalService } from '../../../core/global-tracking-interval/global-tracking-interval.service';
import { OperationWriteFlushService } from '../../../op-log/sync/operation-write-flush.service';
@Injectable()
export class AndroidForegroundTrackingEffects {
@ -37,8 +31,8 @@ export class AndroidForegroundTrackingEffects {
private _dateService = inject(DateService);
private _hydrationState = inject(HydrationStateService);
private _snackService = inject(SnackService);
private _pfapiService = inject(PfapiService);
private _globalTrackingIntervalService = inject(GlobalTrackingIntervalService);
private _operationWriteFlush = inject(OperationWriteFlushService);
/**
* Start/stop the native foreground service when the current task changes.
@ -208,9 +202,9 @@ export class AndroidForegroundTrackingEffects {
DroidLog.log('Pause action from notification');
// Sync elapsed time first and wait for completion
await this._syncElapsedTimeForTask(currentTask!.id);
// Force immediate save to prevent data loss (bypasses 15s debounce)
this._saveTimeTrackingImmediately();
this._taskService.pauseCurrent();
// Flush pending operations to IndexedDB to prevent data loss
this._flushPendingOperations();
}),
),
{ dispatch: false },
@ -232,9 +226,9 @@ export class AndroidForegroundTrackingEffects {
// Sync elapsed time and wait for completion
await this._syncElapsedTimeForTask(currentTask!.id);
this._taskService.setDone(currentTask!.id);
// Force immediate save to prevent data loss (bypasses 15s debounce)
this._saveTimeTrackingImmediately();
this._taskService.pauseCurrent();
// Flush pending operations to IndexedDB to prevent data loss
this._flushPendingOperations();
}),
),
{ dispatch: false },
@ -252,43 +246,15 @@ export class AndroidForegroundTrackingEffects {
}
/**
* Force immediate save of time tracking data to IndexedDB.
* This bypasses the normal 15-second debounce to ensure data is persisted
* Force immediate flush of pending operations to IndexedDB.
* This ensures all dispatched NgRx actions are persisted to the operation log
* before the app can be closed (e.g., after notification button clicks).
*/
private _saveTimeTrackingImmediately(): void {
// Save task state
this._store
.select(selectTaskFeatureState)
.pipe(first())
.subscribe((taskState) => {
this._pfapiService.m.task
.save(
{
...taskState,
selectedTaskId: environment.production ? null : taskState.selectedTaskId,
currentTaskId: null,
},
{ isUpdateRevAndLastUpdate: true },
)
.catch((e) => DroidLog.err('Failed to save task state immediately', e));
});
// Save time tracking state
this._store
.select(selectTimeTrackingState)
.pipe(first())
.subscribe((ttState) => {
this._pfapiService.m.timeTracking
.save(ttState, {
isUpdateRevAndLastUpdate: true,
})
.catch((e) =>
DroidLog.err('Failed to save time tracking state immediately', e),
);
});
DroidLog.log('Forced immediate save of time tracking data');
private _flushPendingOperations(): void {
this._operationWriteFlush.flushPendingWrites().catch((e) => {
DroidLog.err('Failed to flush pending operations', e);
});
DroidLog.log('Triggered immediate flush of pending operations');
}
/**

View file

@ -1,7 +1,7 @@
import { inject, Injectable, Injector } from '@angular/core';
import { PfapiService } from '../../pfapi/pfapi.service';
import { inject, Injectable } from '@angular/core';
import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service';
import { ArchiveTask, TimeSpentOnDayCopy } from '../tasks/task.model';
import { ArchiveModel } from './time-tracking.model';
import { ArchiveModel } from './archive.model';
export interface CompressionPreview {
subtasksToDelete: number;
@ -10,27 +10,25 @@ export interface CompressionPreview {
estimatedSavingsKB: number;
}
const DEFAULT_ARCHIVE: ArchiveModel = {
task: { ids: [], entities: {} },
timeTracking: { project: {}, tag: {} },
lastTimeTrackingFlush: 0,
};
@Injectable({
providedIn: 'root',
})
export class ArchiveCompressionService {
private _injector = inject(Injector);
private _pfapiService?: PfapiService;
private get pfapiService(): PfapiService {
if (!this._pfapiService) {
this._pfapiService = this._injector.get(PfapiService);
}
return this._pfapiService;
}
private _archiveDbAdapter = inject(ArchiveDbAdapter);
/**
* Calculate compression preview statistics for UI display.
*/
async getCompressionPreview(oneYearAgoTimestamp: number): Promise<CompressionPreview> {
const [archiveYoung, archiveOld] = await Promise.all([
this.pfapiService.m.archiveYoung.load(),
this.pfapiService.m.archiveOld.load(),
this._archiveDbAdapter.loadArchiveYoung().then((a) => a ?? DEFAULT_ARCHIVE),
this._archiveDbAdapter.loadArchiveOld().then((a) => a ?? DEFAULT_ARCHIVE),
]);
const allTasks = [
@ -75,27 +73,18 @@ export class ArchiveCompressionService {
* Execute compression on archive data.
* DETERMINISTIC: Same input produces same output across all clients.
*/
async compressArchive(
oneYearAgoTimestamp: number,
isIgnoreDBLock: boolean = false,
): Promise<void> {
async compressArchive(oneYearAgoTimestamp: number): Promise<void> {
const [archiveYoung, archiveOld] = await Promise.all([
this.pfapiService.m.archiveYoung.load(),
this.pfapiService.m.archiveOld.load(),
this._archiveDbAdapter.loadArchiveYoung().then((a) => a ?? DEFAULT_ARCHIVE),
this._archiveDbAdapter.loadArchiveOld().then((a) => a ?? DEFAULT_ARCHIVE),
]);
const newArchiveYoung = this._compressArchiveData(archiveYoung, oneYearAgoTimestamp);
const newArchiveOld = this._compressArchiveData(archiveOld, oneYearAgoTimestamp);
await Promise.all([
this.pfapiService.m.archiveYoung.save(newArchiveYoung, {
isUpdateRevAndLastUpdate: true,
isIgnoreDBLock: isIgnoreDBLock ? true : undefined,
}),
this.pfapiService.m.archiveOld.save(newArchiveOld, {
isUpdateRevAndLastUpdate: true,
isIgnoreDBLock: isIgnoreDBLock ? true : undefined,
}),
this._archiveDbAdapter.saveArchiveYoung(newArchiveYoung),
this._archiveDbAdapter.saveArchiveOld(newArchiveOld),
]);
}

View file

@ -0,0 +1,15 @@
import { TaskArchive } from '../tasks/task.model';
import { TimeTrackingState } from '../time-tracking/time-tracking.model';
/**
* Model for archived task and time tracking data.
* Archives are split into "young" (recent, < 21 days) and "old" (>= 21 days).
*/
export interface ArchiveModel {
/**
* Should not be written apart from flushing!
*/
timeTracking: TimeTrackingState;
task: TaskArchive;
lastTimeTrackingFlush: number;
}

View file

@ -1,42 +1,22 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { ArchiveService, ARCHIVE_ALL_YOUNG_TO_OLD_THRESHOLD } from './archive.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { flushYoungToOld } from './store/archive.actions';
import { TimeTrackingActions } from './store/time-tracking.actions';
import { TimeTrackingActions } from '../time-tracking/store/time-tracking.actions';
import { TaskWithSubTasks } from '../tasks/task.model';
import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service';
import { of } from 'rxjs';
import { ArchiveModel } from './archive.model';
describe('ArchiveService', () => {
let service: ArchiveService;
let mockStore: jasmine.SpyObj<Store>;
let mockPfapiService: {
m: {
archiveYoung: {
load: jasmine.Spy;
save: jasmine.Spy;
};
archiveOld: {
load: jasmine.Spy;
save: jasmine.Spy;
};
timeTracking: {
load: jasmine.Spy;
};
};
};
let mockArchiveDbAdapter: jasmine.SpyObj<ArchiveDbAdapter>;
const createEmptyArchive = (
lastTimeTrackingFlush: number = 0,
): {
task: { ids: string[]; entities: Record<string, unknown> };
timeTracking: { project: Record<string, unknown>; tag: Record<string, unknown> };
lastTimeTrackingFlush: number;
lastFlush: number;
} => ({
const createEmptyArchive = (lastTimeTrackingFlush: number = 0): ArchiveModel => ({
task: { ids: [], entities: {} },
timeTracking: { project: {}, tag: {} },
lastTimeTrackingFlush,
lastFlush: 0,
});
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
@ -59,46 +39,36 @@ describe('ArchiveService', () => {
}) as TaskWithSubTasks;
beforeEach(() => {
mockStore = jasmine.createSpyObj('Store', ['dispatch']);
mockStore = jasmine.createSpyObj('Store', ['dispatch', 'select']);
// Mock store.select to return time tracking state
mockStore.select.and.returnValue(of({ project: {}, tag: {} }));
mockPfapiService = {
m: {
archiveYoung: {
load: jasmine.createSpy('archiveYoung.load'),
save: jasmine.createSpy('archiveYoung.save'),
},
archiveOld: {
load: jasmine.createSpy('archiveOld.load'),
save: jasmine.createSpy('archiveOld.save'),
},
timeTracking: {
load: jasmine.createSpy('timeTracking.load'),
},
},
};
mockArchiveDbAdapter = jasmine.createSpyObj('ArchiveDbAdapter', [
'loadArchiveYoung',
'loadArchiveOld',
'saveArchiveYoung',
'saveArchiveOld',
]);
TestBed.configureTestingModule({
providers: [
ArchiveService,
{ provide: Store, useValue: mockStore },
{ provide: PfapiService, useValue: mockPfapiService },
{ provide: ArchiveDbAdapter, useValue: mockArchiveDbAdapter },
],
});
service = TestBed.inject(ArchiveService);
// Default mock returns
mockPfapiService.m.archiveYoung.load.and.returnValue(
mockArchiveDbAdapter.loadArchiveYoung.and.returnValue(
Promise.resolve(createEmptyArchive()),
);
mockPfapiService.m.archiveOld.load.and.returnValue(
mockArchiveDbAdapter.loadArchiveOld.and.returnValue(
Promise.resolve(createEmptyArchive()),
);
mockPfapiService.m.timeTracking.load.and.returnValue(
Promise.resolve({ project: {}, tag: {} }),
);
mockPfapiService.m.archiveYoung.save.and.returnValue(Promise.resolve());
mockPfapiService.m.archiveOld.save.and.returnValue(Promise.resolve());
mockArchiveDbAdapter.saveArchiveYoung.and.returnValue(Promise.resolve());
mockArchiveDbAdapter.saveArchiveOld.and.returnValue(Promise.resolve());
});
it('should be created', () => {
@ -111,8 +81,8 @@ describe('ArchiveService', () => {
await service.moveTasksToArchiveAndFlushArchiveIfDue(tasks);
expect(mockPfapiService.m.archiveYoung.save).toHaveBeenCalled();
const saveCall = mockPfapiService.m.archiveYoung.save.calls.first();
expect(mockArchiveDbAdapter.saveArchiveYoung).toHaveBeenCalled();
const saveCall = mockArchiveDbAdapter.saveArchiveYoung.calls.first();
const savedData = saveCall.args[0];
expect(savedData.task.ids).toContain('task-1');
});
@ -130,7 +100,7 @@ describe('ArchiveService', () => {
describe('when flush is NOT due', () => {
beforeEach(() => {
// Set lastTimeTrackingFlush to recent time (less than threshold)
mockPfapiService.m.archiveOld.load.and.returnValue(
mockArchiveDbAdapter.loadArchiveOld.and.returnValue(
Promise.resolve(createEmptyArchive(Date.now() - 1000)), // 1 second ago
);
});
@ -141,8 +111,8 @@ describe('ArchiveService', () => {
await service.moveTasksToArchiveAndFlushArchiveIfDue(tasks);
// Should only save once (for the tasks), not for the flush
expect(mockPfapiService.m.archiveYoung.save).toHaveBeenCalledTimes(1);
expect(mockPfapiService.m.archiveOld.save).not.toHaveBeenCalled();
expect(mockArchiveDbAdapter.saveArchiveYoung).toHaveBeenCalledTimes(1);
expect(mockArchiveDbAdapter.saveArchiveOld).not.toHaveBeenCalled();
});
it('should NOT dispatch flushYoungToOld action', async () => {
@ -161,7 +131,7 @@ describe('ArchiveService', () => {
beforeEach(() => {
// Set lastTimeTrackingFlush to old time (more than threshold)
mockPfapiService.m.archiveOld.load.and.returnValue(
mockArchiveDbAdapter.loadArchiveOld.and.returnValue(
Promise.resolve(createEmptyArchive(oldFlushTime)),
);
});
@ -174,7 +144,7 @@ describe('ArchiveService', () => {
// The key behavior: archiveOld.save was called, meaning flush happened
// synchronously before the method returned (and before dispatch could
// cause any async effects)
expect(mockPfapiService.m.archiveOld.save).toHaveBeenCalledTimes(1);
expect(mockArchiveDbAdapter.saveArchiveOld).toHaveBeenCalledTimes(1);
});
it('should save to both archiveYoung and archiveOld during flush', async () => {
@ -183,8 +153,8 @@ describe('ArchiveService', () => {
await service.moveTasksToArchiveAndFlushArchiveIfDue(tasks);
// archiveYoung.save called twice: once for tasks, once for flush
expect(mockPfapiService.m.archiveYoung.save).toHaveBeenCalledTimes(2);
expect(mockPfapiService.m.archiveOld.save).toHaveBeenCalledTimes(1);
expect(mockArchiveDbAdapter.saveArchiveYoung).toHaveBeenCalledTimes(2);
expect(mockArchiveDbAdapter.saveArchiveOld).toHaveBeenCalledTimes(1);
});
it('should dispatch flushYoungToOld action AFTER saves complete', async () => {
@ -217,11 +187,12 @@ describe('ArchiveService', () => {
await service.moveTasksToArchiveAndFlushArchiveIfDue(tasks);
// Check archiveYoung flush save (second call)
const archiveYoungFlushCall = mockPfapiService.m.archiveYoung.save.calls.all()[1];
const archiveYoungFlushCall =
mockArchiveDbAdapter.saveArchiveYoung.calls.all()[1];
expect(archiveYoungFlushCall.args[0].lastTimeTrackingFlush).toBeDefined();
// Check archiveOld save
const archiveOldCall = mockPfapiService.m.archiveOld.save.calls.first();
const archiveOldCall = mockArchiveDbAdapter.saveArchiveOld.calls.first();
expect(archiveOldCall.args[0].lastTimeTrackingFlush).toBeDefined();
});
});
@ -229,7 +200,7 @@ describe('ArchiveService', () => {
it('should do nothing if no tasks provided', async () => {
await service.moveTasksToArchiveAndFlushArchiveIfDue([]);
expect(mockPfapiService.m.archiveYoung.save).not.toHaveBeenCalled();
expect(mockArchiveDbAdapter.saveArchiveYoung).not.toHaveBeenCalled();
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
@ -238,7 +209,7 @@ describe('ArchiveService', () => {
beforeEach(() => {
// Set up conditions for flush to be triggered
mockPfapiService.m.archiveOld.load.and.returnValue(
mockArchiveDbAdapter.loadArchiveOld.and.returnValue(
Promise.resolve(createEmptyArchive(oldFlushTime)),
);
});
@ -248,7 +219,7 @@ describe('ArchiveService', () => {
// First save (for tasks) succeeds, second save (for flush) fails
let saveCallCount = 0;
mockPfapiService.m.archiveYoung.save.and.callFake(() => {
mockArchiveDbAdapter.saveArchiveYoung.and.callFake(() => {
saveCallCount++;
if (saveCallCount === 2) {
return Promise.reject(new Error('archiveYoung.save failed during flush'));
@ -268,7 +239,7 @@ describe('ArchiveService', () => {
it('should NOT dispatch flushYoungToOld if archiveOld.save fails', async () => {
const tasks = [createMockTask('task-1')];
mockPfapiService.m.archiveOld.save.and.returnValue(
mockArchiveDbAdapter.saveArchiveOld.and.returnValue(
Promise.reject(new Error('archiveOld.save failed')),
);
@ -286,15 +257,15 @@ describe('ArchiveService', () => {
const originalArchiveYoung = createEmptyArchive();
const originalArchiveOld = createEmptyArchive(oldFlushTime);
mockPfapiService.m.archiveYoung.load.and.returnValue(
mockArchiveDbAdapter.loadArchiveYoung.and.returnValue(
Promise.resolve(originalArchiveYoung),
);
mockPfapiService.m.archiveOld.load.and.returnValue(
mockArchiveDbAdapter.loadArchiveOld.and.returnValue(
Promise.resolve(originalArchiveOld),
);
// archiveOld.save fails on first call
mockPfapiService.m.archiveOld.save.and.returnValue(
mockArchiveDbAdapter.saveArchiveOld.and.returnValue(
Promise.reject(new Error('archiveOld.save failed')),
);
@ -305,10 +276,10 @@ describe('ArchiveService', () => {
// Rollback should attempt to save both archives again
// archiveYoung: 1st call for tasks, 2nd call for flush (try), 3rd call for rollback
// archiveOld: 1st call for flush (fails), 2nd call for rollback
expect(mockPfapiService.m.archiveYoung.save.calls.count()).toBeGreaterThanOrEqual(
3,
);
expect(mockPfapiService.m.archiveOld.save.calls.count()).toBeGreaterThanOrEqual(
expect(
mockArchiveDbAdapter.saveArchiveYoung.calls.count(),
).toBeGreaterThanOrEqual(3);
expect(mockArchiveDbAdapter.saveArchiveOld.calls.count()).toBeGreaterThanOrEqual(
2,
);
});
@ -316,7 +287,7 @@ describe('ArchiveService', () => {
it('should re-throw original error after rollback succeeds', async () => {
const tasks = [createMockTask('task-1')];
mockPfapiService.m.archiveOld.save.and.returnValue(
mockArchiveDbAdapter.saveArchiveOld.and.returnValue(
Promise.reject(new Error('Original flush error')),
);
@ -330,7 +301,7 @@ describe('ArchiveService', () => {
// Track call count to make archiveOld.save fail on flush but also on rollback
let archiveOldSaveCount = 0;
mockPfapiService.m.archiveOld.save.and.callFake(() => {
mockArchiveDbAdapter.saveArchiveOld.and.callFake(() => {
archiveOldSaveCount++;
if (archiveOldSaveCount === 1) {
return Promise.reject(new Error('Original flush error'));
@ -348,7 +319,7 @@ describe('ArchiveService', () => {
// archiveOld.save fails on flush
let archiveOldSaveCount = 0;
mockPfapiService.m.archiveOld.save.and.callFake(() => {
mockArchiveDbAdapter.saveArchiveOld.and.callFake(() => {
archiveOldSaveCount++;
if (archiveOldSaveCount === 1) {
return Promise.reject(new Error('Original flush error'));
@ -358,7 +329,7 @@ describe('ArchiveService', () => {
// archiveYoung.save fails on rollback (3rd call)
let archiveYoungSaveCount = 0;
mockPfapiService.m.archiveYoung.save.and.callFake(() => {
mockArchiveDbAdapter.saveArchiveYoung.and.callFake(() => {
archiveYoungSaveCount++;
if (archiveYoungSaveCount === 3) {
return Promise.reject(new Error('archiveYoung rollback failed'));
@ -371,7 +342,7 @@ describe('ArchiveService', () => {
).toBeRejected();
// Should still attempt to rollback archiveOld even though archiveYoung rollback failed
expect(mockPfapiService.m.archiveOld.save.calls.count()).toBe(2);
expect(mockArchiveDbAdapter.saveArchiveOld.calls.count()).toBe(2);
});
});
});

View file

@ -1,18 +1,22 @@
import { inject, Injectable, Injector } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { Task, TaskWithSubTasks } from '../tasks/task.model';
import { flattenTasks } from '../tasks/store/task.selectors';
import { createEmptyEntity } from '../../util/create-empty-entity';
import { taskAdapter } from '../tasks/store/task.adapter';
import { PfapiService } from '../../pfapi/pfapi.service';
import {
sortTimeTrackingAndTasksFromArchiveYoungToOld,
sortTimeTrackingDataToArchiveYoung,
} from './sort-data-to-flush';
} from './util/sort-data-to-flush';
import { Store } from '@ngrx/store';
import { TimeTrackingActions } from './store/time-tracking.actions';
import { TimeTrackingActions } from '../time-tracking/store/time-tracking.actions';
import { flushYoungToOld } from './store/archive.actions';
import { getDbDateStr } from '../../util/get-db-date-str';
import { Log } from '../../core/log';
import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service';
import { ArchiveModel } from './archive.model';
import { TimeTrackingState } from '../time-tracking/time-tracking.model';
import { first } from 'rxjs/operators';
import { selectTimeTrackingState } from '../time-tracking/store/time-tracking.selectors';
/**
* Maps tasks to archive format by:
@ -81,21 +85,22 @@ taskArchive:
export const ARCHIVE_ALL_YOUNG_TO_OLD_THRESHOLD = 1000 * 60 * 60 * 24 * 14;
export const ARCHIVE_TASK_YOUNG_TO_OLD_THRESHOLD = 1000 * 60 * 60 * 24 * 21;
const DEFAULT_TIME_TRACKING: TimeTrackingState = {
project: {},
tag: {},
};
const DEFAULT_ARCHIVE: ArchiveModel = {
task: createEmptyEntity(),
timeTracking: DEFAULT_TIME_TRACKING,
lastTimeTrackingFlush: 0,
};
@Injectable({
providedIn: 'root',
})
export class ArchiveService {
// Use lazy injection to break circular dependency:
// PfapiService -> Pfapi -> OperationLogSyncService -> OperationApplierService
// -> ArchiveOperationHandler -> ArchiveService -> PfapiService
private readonly _injector = inject(Injector);
private _pfapiService?: PfapiService;
private get pfapiService(): PfapiService {
if (!this._pfapiService) {
this._pfapiService = this._injector.get(PfapiService);
}
return this._pfapiService;
}
private readonly _archiveDbAdapter = inject(ArchiveDbAdapter);
private readonly _store = inject(Store);
// NOTE: we choose this method as trigger to check for flushing to archive, since
@ -115,7 +120,8 @@ export class ArchiveService {
return;
}
const archiveYoung = await this.pfapiService.m.archiveYoung.load();
const archiveYoung =
(await this._archiveDbAdapter.loadArchiveYoung()) || DEFAULT_ARCHIVE;
const taskArchiveState = archiveYoung.task || createEmptyEntity();
const archiveTasks = mapTasksToArchiveFormat(flatTasks, now, 'moveToArchive');
@ -130,9 +136,13 @@ export class ArchiveService {
// Result A:
// Move all archived tasks to archiveYoung
// Move timeTracking data to archiveYoung
// Get time tracking from the store as it is fresher
const timeTracking = await this._store
.select(selectTimeTrackingState)
.pipe(first())
.toPromise();
const newSorted1 = sortTimeTrackingDataToArchiveYoung({
// TODO think if it is better to get this from store as it is fresher potentially
timeTracking: await this.pfapiService.m.timeTracking.load(),
timeTracking: timeTracking || DEFAULT_TIME_TRACKING,
archiveYoung,
todayStr: getDbDateStr(now),
});
@ -143,9 +153,7 @@ export class ArchiveService {
// This is different from lastTimeTrackingFlush which tracks young→old flushes.
lastFlush: now,
};
await this.pfapiService.m.archiveYoung.save(newArchiveYoung, {
isUpdateRevAndLastUpdate: true,
});
await this._archiveDbAdapter.saveArchiveYoung(newArchiveYoung);
Log.log('[ArchiveService] Saved tasks to archiveYoung:', {
archivedTaskCount: Object.keys(newTaskArchive.entities).length,
@ -160,7 +168,7 @@ export class ArchiveService {
// ------------------------------------------------
// Check if it's time to flush archiveYoung to archiveOld
const archiveOld = await this.pfapiService.m.archiveOld.load();
const archiveOld = (await this._archiveDbAdapter.loadArchiveOld()) || DEFAULT_ARCHIVE;
const isFlushArchiveOld =
now - archiveOld.lastTimeTrackingFlush > ARCHIVE_ALL_YOUNG_TO_OLD_THRESHOLD;
@ -193,21 +201,15 @@ export class ArchiveService {
});
try {
await this.pfapiService.m.archiveYoung.save(
{
...newSorted.archiveYoung,
lastTimeTrackingFlush: now,
},
{ isUpdateRevAndLastUpdate: true },
);
await this._archiveDbAdapter.saveArchiveYoung({
...newSorted.archiveYoung,
lastTimeTrackingFlush: now,
});
await this.pfapiService.m.archiveOld.save(
{
...newSorted.archiveOld,
lastTimeTrackingFlush: now,
},
{ isUpdateRevAndLastUpdate: true },
);
await this._archiveDbAdapter.saveArchiveOld({
...newSorted.archiveOld,
lastTimeTrackingFlush: now,
});
} catch (e) {
// Attempt rollback: restore BOTH archiveYoung and archiveOld to original state
Log.err('[ArchiveService] Archive flush failed, attempting rollback...', e);
@ -215,18 +217,14 @@ export class ArchiveService {
// Rollback archiveYoung
try {
await this.pfapiService.m.archiveYoung.save(originalArchiveYoung, {
isUpdateRevAndLastUpdate: true,
});
await this._archiveDbAdapter.saveArchiveYoung(originalArchiveYoung);
} catch (rollbackErr) {
rollbackErrors.push(rollbackErr as Error);
}
// Rollback archiveOld
try {
await this.pfapiService.m.archiveOld.save(originalArchiveOld, {
isUpdateRevAndLastUpdate: true,
});
await this._archiveDbAdapter.saveArchiveOld(originalArchiveOld);
} catch (rollbackErr) {
rollbackErrors.push(rollbackErr as Error);
}
@ -280,7 +278,8 @@ export class ArchiveService {
return;
}
const archiveYoung = await this.pfapiService.m.archiveYoung.load();
const archiveYoung =
(await this._archiveDbAdapter.loadArchiveYoung()) || DEFAULT_ARCHIVE;
const taskArchiveState = archiveYoung.task || createEmptyEntity();
const archiveTasks = mapTasksToArchiveFormat(flatTasks, now, 'Remote sync');
@ -293,23 +292,23 @@ export class ArchiveService {
// Also move historical time tracking data to archiveYoung
// This ensures the remote client's archive matches the originating client
const timeTracking = await this.pfapiService.m.timeTracking.load();
// Get time tracking from the store as it is fresher
const timeTracking = await this._store
.select(selectTimeTrackingState)
.pipe(first())
.toPromise();
const sorted = sortTimeTrackingDataToArchiveYoung({
timeTracking,
timeTracking: timeTracking || DEFAULT_TIME_TRACKING,
archiveYoung,
todayStr: getDbDateStr(now),
});
await this.pfapiService.m.archiveYoung.save(
{
...sorted.archiveYoung,
task: newTaskArchive,
},
{
isUpdateRevAndLastUpdate: false, // Don't update rev for remote sync
isIgnoreDBLock: true, // Called during sync when DB is locked
},
);
// Note: ArchiveDbAdapter uses direct IndexedDB access (same DB as PFAPI)
// so there's no conflict with PFAPI's lock mechanism
await this._archiveDbAdapter.saveArchiveYoung({
...sorted.archiveYoung,
task: newTaskArchive,
});
// Update active time tracking state (remove historical data that was moved to archive)
this._store.dispatch(

View file

@ -1,6 +1,6 @@
import { createFeatureSelector, createReducer, on } from '@ngrx/store';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { ArchiveModel } from '../time-tracking.model';
import { ArchiveModel } from '../archive.model';
export const ARCHIVE_OLD_FEATURE_NAME = 'archiveOld';

View file

@ -1,6 +1,6 @@
import { createFeatureSelector, createReducer, on } from '@ngrx/store';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { ArchiveModel } from '../time-tracking.model';
import { ArchiveModel } from '../archive.model';
export const ARCHIVE_YOUNG_FEATURE_NAME = 'archiveYoung';

View file

@ -1,25 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TaskArchiveService } from './task-archive.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service';
import { Task, TaskArchive, TaskState } from '../tasks/task.model';
import { ArchiveModel } from './time-tracking.model';
import { ArchiveModel } from './archive.model';
import { Update } from '@ngrx/entity';
import { RoundTimeOption } from '../project/project.model';
import { ModelCtrl } from '../../pfapi/api/model-ctrl/model-ctrl';
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
describe('TaskArchiveService', () => {
let service: TaskArchiveService;
let storeMock: jasmine.SpyObj<Store>;
let pfapiServiceMock: {
m: {
archiveYoung: jasmine.SpyObj<ModelCtrl<ArchiveModel>>;
archiveOld: jasmine.SpyObj<ModelCtrl<ArchiveModel>>;
};
};
let archiveYoungMock: jasmine.SpyObj<ModelCtrl<ArchiveModel>>;
let archiveOldMock: jasmine.SpyObj<ModelCtrl<ArchiveModel>>;
let archiveDbAdapterMock: jasmine.SpyObj<ArchiveDbAdapter>;
const createMockTask = (id: string, overrides: Partial<Task> = {}): Task => ({
id,
@ -62,31 +55,25 @@ describe('TaskArchiveService', () => {
beforeEach(() => {
storeMock = jasmine.createSpyObj<Store>('Store', ['dispatch']);
archiveYoungMock = jasmine.createSpyObj<ModelCtrl<ArchiveModel>>('archiveYoung', [
'load',
'save',
archiveDbAdapterMock = jasmine.createSpyObj<ArchiveDbAdapter>('ArchiveDbAdapter', [
'loadArchiveYoung',
'saveArchiveYoung',
'loadArchiveOld',
'saveArchiveOld',
]);
archiveYoungMock.load.and.returnValue(Promise.resolve(createMockArchiveModel([])));
archiveYoungMock.save.and.returnValue(Promise.resolve());
archiveOldMock = jasmine.createSpyObj<ModelCtrl<ArchiveModel>>('archiveOld', [
'load',
'save',
]);
archiveOldMock.load.and.returnValue(Promise.resolve(createMockArchiveModel([])));
archiveOldMock.save.and.returnValue(Promise.resolve());
pfapiServiceMock = {
m: {
archiveYoung: archiveYoungMock,
archiveOld: archiveOldMock,
},
};
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(createMockArchiveModel([])),
);
archiveDbAdapterMock.saveArchiveYoung.and.returnValue(Promise.resolve());
archiveDbAdapterMock.loadArchiveOld.and.returnValue(
Promise.resolve(createMockArchiveModel([])),
);
archiveDbAdapterMock.saveArchiveOld.and.returnValue(Promise.resolve());
TestBed.configureTestingModule({
providers: [
TaskArchiveService,
{ provide: PfapiService, useValue: pfapiServiceMock },
{ provide: ArchiveDbAdapter, useValue: archiveDbAdapterMock },
{ provide: Store, useValue: storeMock },
],
});
@ -103,14 +90,14 @@ describe('TaskArchiveService', () => {
const mockTasks = [createMockTask('task1'), createMockTask('task2')];
const mockArchive = createMockArchiveModel(mockTasks);
archiveYoungMock.load.and.returnValue(Promise.resolve(mockArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(Promise.resolve(mockArchive));
const result = await service.loadYoung();
expect(result.ids).toEqual(['task1', 'task2']);
expect(result.entities['task1']).toBeDefined();
expect(result.entities['task2']).toBeDefined();
expect(archiveYoungMock.load).toHaveBeenCalled();
expect(archiveDbAdapterMock.loadArchiveYoung).toHaveBeenCalled();
});
});
@ -121,16 +108,18 @@ describe('TaskArchiveService', () => {
const youngArchive = createMockArchiveModel(youngTasks);
const oldArchive = createMockArchiveModel(oldTasks);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
const result = await service.load();
expect(result.ids).toEqual(['young1', 'young2', 'old1', 'old2']);
expect(result.entities['young1']).toBeDefined();
expect(result.entities['old2']).toBeDefined();
expect(archiveYoungMock.load).toHaveBeenCalled();
expect(archiveOldMock.load).toHaveBeenCalled();
expect(archiveDbAdapterMock.loadArchiveYoung).toHaveBeenCalled();
expect(archiveDbAdapterMock.loadArchiveOld).toHaveBeenCalled();
});
});
@ -140,14 +129,16 @@ describe('TaskArchiveService', () => {
const youngArchive = createMockArchiveModel([task]);
const oldArchive = createMockArchiveModel([]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
const result = await service.getById('task1');
expect(result).toEqual(task);
expect(archiveYoungMock.load).toHaveBeenCalled();
expect(archiveOldMock.load).not.toHaveBeenCalled();
expect(archiveDbAdapterMock.loadArchiveYoung).toHaveBeenCalled();
expect(archiveDbAdapterMock.loadArchiveOld).not.toHaveBeenCalled();
});
it('should find task in old archive if not in young', async () => {
@ -155,22 +146,26 @@ describe('TaskArchiveService', () => {
const youngArchive = createMockArchiveModel([]);
const oldArchive = createMockArchiveModel([task]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
const result = await service.getById('task1');
expect(result).toEqual(task);
expect(archiveYoungMock.load).toHaveBeenCalled();
expect(archiveOldMock.load).toHaveBeenCalled();
expect(archiveDbAdapterMock.loadArchiveYoung).toHaveBeenCalled();
expect(archiveDbAdapterMock.loadArchiveOld).toHaveBeenCalled();
});
it('should throw error if task not found', async () => {
const youngArchive = createMockArchiveModel([]);
const oldArchive = createMockArchiveModel([]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
await expectAsync(service.getById('nonexistent')).toBeRejectedWithError(
'Archive task not found by id',
@ -187,16 +182,18 @@ describe('TaskArchiveService', () => {
const youngArchive = createMockArchiveModel([task1, task2, unrelatedTask]);
const oldArchive = createMockArchiveModel([]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
await service.deleteTasks(['task1']);
const saveCall = archiveYoungMock.save.calls.mostRecent();
const saveCall = archiveDbAdapterMock.saveArchiveYoung.calls.mostRecent();
const savedArchive = saveCall.args[0];
// The task reducer should handle the delete action
expect(archiveYoungMock.save).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveYoung).toHaveBeenCalled();
expect(savedArchive).toBeDefined();
// Note: The actual deletion logic depends on how the taskReducer handles TaskSharedActions.deleteTasks
@ -210,13 +207,15 @@ describe('TaskArchiveService', () => {
const youngArchive = createMockArchiveModel([youngTask]);
const oldArchive = createMockArchiveModel([oldTask]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
await service.deleteTasks(['young1', 'old1']);
expect(archiveYoungMock.save).toHaveBeenCalled();
expect(archiveOldMock.save).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveYoung).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveOld).toHaveBeenCalled();
// Note: The actual deletion is handled by the taskReducer
// We're just verifying that the service calls save on both archives
@ -232,8 +231,12 @@ describe('TaskArchiveService', () => {
});
const youngArchive = createMockArchiveModel([task]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(createMockArchiveModel([])));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(
Promise.resolve(createMockArchiveModel([])),
);
const newTimeSpentOnDay = {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -248,7 +251,7 @@ describe('TaskArchiveService', () => {
timeSpentOnDay: newTimeSpentOnDay,
});
const saveCall = archiveYoungMock.save.calls.mostRecent();
const saveCall = archiveDbAdapterMock.saveArchiveYoung.calls.mostRecent();
const savedArchive = saveCall.args[0];
const updatedTask = savedArchive.task.entities['task1'] as Task;
@ -260,12 +263,14 @@ describe('TaskArchiveService', () => {
const task = createMockTask('task1', { isDone: false, doneOn: undefined });
const youngArchive = createMockArchiveModel([task]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
// Mark as done
await service.updateTask('task1', { isDone: true });
let saveCall = archiveYoungMock.save.calls.mostRecent();
let saveCall = archiveDbAdapterMock.saveArchiveYoung.calls.mostRecent();
let savedArchive = saveCall.args[0];
let updatedTask = savedArchive.task.entities['task1'] as Task;
@ -274,10 +279,12 @@ describe('TaskArchiveService', () => {
expect(updatedTask.dueDay).toBeUndefined();
// Mark as undone
archiveYoungMock.load.and.returnValue(Promise.resolve(savedArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(savedArchive),
);
await service.updateTask('task1', { isDone: false });
saveCall = archiveYoungMock.save.calls.mostRecent();
saveCall = archiveDbAdapterMock.saveArchiveYoung.calls.mostRecent();
savedArchive = saveCall.args[0];
updatedTask = savedArchive.task.entities['task1'] as Task;
@ -289,8 +296,10 @@ describe('TaskArchiveService', () => {
const youngArchive = createMockArchiveModel([]);
const oldArchive = createMockArchiveModel([]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
await expectAsync(
service.updateTask('nonexistent', { title: 'New Title' }),
@ -301,7 +310,9 @@ describe('TaskArchiveService', () => {
const task = createMockTask('task1', { title: 'Original' });
const youngArchive = createMockArchiveModel([task]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
await service.updateTask('task1', { title: 'Updated' });
@ -316,35 +327,24 @@ describe('TaskArchiveService', () => {
const task = createMockTask('task1', { title: 'Original' });
const youngArchive = createMockArchiveModel([task]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
await service.updateTask('task1', { title: 'Updated' }, { isSkipDispatch: true });
expect(storeMock.dispatch).not.toHaveBeenCalled();
});
it('should pass isIgnoreDBLock to save when provided', async () => {
const task = createMockTask('task1', { title: 'Original' });
const youngArchive = createMockArchiveModel([task]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
await service.updateTask('task1', { title: 'Updated' }, { isIgnoreDBLock: true });
const saveCall = archiveYoungMock.save.calls.mostRecent();
expect(saveCall.args[1]).toEqual({
isUpdateRevAndLastUpdate: true,
isIgnoreDBLock: true,
});
});
it('should dispatch action for task in archiveOld', async () => {
const task = createMockTask('task1', { title: 'Original' });
const youngArchive = createMockArchiveModel([]);
const oldArchive = createMockArchiveModel([task]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
await service.updateTask('task1', { title: 'Updated' });
@ -365,8 +365,10 @@ describe('TaskArchiveService', () => {
const youngArchive = createMockArchiveModel([youngTask1, youngTask2]);
const oldArchive = createMockArchiveModel([oldTask1]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
const updates: Update<Task>[] = [
{ id: 'young1', changes: { title: 'Updated Young 1' } },
@ -378,8 +380,8 @@ describe('TaskArchiveService', () => {
// The updateTasks method uses the taskReducer which doesn't fully apply updates in unit tests
// We just verify that save was called on both archives
expect(archiveYoungMock.save).toHaveBeenCalled();
expect(archiveOldMock.save).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveYoung).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveOld).toHaveBeenCalled();
});
it('should dispatch persistent actions for each update by default', async () => {
@ -387,8 +389,12 @@ describe('TaskArchiveService', () => {
const task2 = createMockTask('task2', { title: 'Task 2' });
const youngArchive = createMockArchiveModel([task1, task2]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(createMockArchiveModel([])));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(
Promise.resolve(createMockArchiveModel([])),
);
const updates: Update<Task>[] = [
{ id: 'task1', changes: { title: 'Updated 1' } },
@ -414,8 +420,12 @@ describe('TaskArchiveService', () => {
const task1 = createMockTask('task1', { title: 'Task 1' });
const youngArchive = createMockArchiveModel([task1]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(createMockArchiveModel([])));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(
Promise.resolve(createMockArchiveModel([])),
);
const updates: Update<Task>[] = [{ id: 'task1', changes: { title: 'Updated' } }];
@ -423,24 +433,6 @@ describe('TaskArchiveService', () => {
expect(storeMock.dispatch).not.toHaveBeenCalled();
});
it('should pass isIgnoreDBLock to save when provided', async () => {
const task1 = createMockTask('task1', { title: 'Task 1' });
const youngArchive = createMockArchiveModel([task1]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(createMockArchiveModel([])));
const updates: Update<Task>[] = [{ id: 'task1', changes: { title: 'Updated' } }];
await service.updateTasks(updates, { isIgnoreDBLock: true });
const saveCall = archiveYoungMock.save.calls.mostRecent();
expect(saveCall.args[1]).toEqual({
isUpdateRevAndLastUpdate: true,
isIgnoreDBLock: true,
});
});
});
describe('roundTimeSpent', () => {
@ -465,8 +457,12 @@ describe('TaskArchiveService', () => {
});
const youngArchive = createMockArchiveModel([task1, task2]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(createMockArchiveModel([])));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(
Promise.resolve(createMockArchiveModel([])),
);
await service.roundTimeSpent({
day: '2024-01-01',
@ -478,8 +474,8 @@ describe('TaskArchiveService', () => {
// The roundTimeSpent method uses the taskReducer which handles the rounding logic
// We just verify that save was called with updated state
expect(archiveYoungMock.save).toHaveBeenCalled();
const saveCall = archiveYoungMock.save.calls.mostRecent();
expect(archiveDbAdapterMock.saveArchiveYoung).toHaveBeenCalled();
const saveCall = archiveDbAdapterMock.saveArchiveYoung.calls.mostRecent();
const savedArchive = saveCall.args[0];
expect(savedArchive.task).toBeDefined();
});
@ -499,8 +495,10 @@ describe('TaskArchiveService', () => {
const youngArchive = createMockArchiveModel([youngTask]);
const oldArchive = createMockArchiveModel([oldTask]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
await service.roundTimeSpent({
day: '2024-01-01',
@ -509,8 +507,8 @@ describe('TaskArchiveService', () => {
isRoundUp: false,
});
expect(archiveYoungMock.save).toHaveBeenCalled();
expect(archiveOldMock.save).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveYoung).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveOld).toHaveBeenCalled();
});
});
@ -524,8 +522,10 @@ describe('TaskArchiveService', () => {
const youngArchive = createMockArchiveModel([task1, task2]);
const oldArchive = createMockArchiveModel([task3, task4]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
// Mock the updateTasks method to track calls
spyOn(service, 'updateTasks').and.returnValue(Promise.resolve());
@ -547,8 +547,10 @@ describe('TaskArchiveService', () => {
const mockArchive = createMockArchiveModel([task1, task2]);
archiveYoungMock.load.and.returnValue(Promise.resolve(mockArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(createMockArchiveModel([])));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(Promise.resolve(mockArchive));
archiveDbAdapterMock.loadArchiveOld.and.returnValue(
Promise.resolve(createMockArchiveModel([])),
);
spyOn(service, 'updateTasks').and.returnValue(Promise.resolve());
@ -579,8 +581,10 @@ describe('TaskArchiveService', () => {
const youngArchive = createMockArchiveModel([task1, task2]);
const oldArchive = createMockArchiveModel([task3]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
spyOn(service, 'updateTasks').and.returnValue(Promise.resolve());
@ -625,8 +629,10 @@ describe('TaskArchiveService', () => {
const mockArchive = createMockArchiveModel([task1, task2]);
archiveYoungMock.load.and.returnValue(Promise.resolve(mockArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(createMockArchiveModel([])));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(Promise.resolve(mockArchive));
archiveDbAdapterMock.loadArchiveOld.and.returnValue(
Promise.resolve(createMockArchiveModel([])),
);
spyOn(service, 'updateTasks').and.returnValue(Promise.resolve());
@ -668,14 +674,16 @@ describe('TaskArchiveService', () => {
const archiveYoung = createMockArchiveModel([taskWithTags]);
const archiveOld = createMockArchiveModel([]);
archiveYoungMock.load.and.returnValue(Promise.resolve(archiveYoung));
archiveOldMock.load.and.returnValue(Promise.resolve(archiveOld));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(archiveYoung),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(archiveOld));
spyOn(service, 'deleteTasks').and.returnValue(Promise.resolve());
await service.removeTagsFromAllTasks(['tag1']);
const saveArgs = archiveYoungMock.save.calls.mostRecent().args;
const saveArgs = archiveDbAdapterMock.saveArchiveYoung.calls.mostRecent().args;
const updatedTask = saveArgs[0].task.entities['task1'] as Task;
expect(updatedTask.tagIds).toEqual(['tag2']);
@ -686,14 +694,16 @@ describe('TaskArchiveService', () => {
const archiveYoung = createMockArchiveModel([]);
const archiveOld = createMockArchiveModel([taskWithTags]);
archiveYoungMock.load.and.returnValue(Promise.resolve(archiveYoung));
archiveOldMock.load.and.returnValue(Promise.resolve(archiveOld));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(archiveYoung),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(archiveOld));
spyOn(service, 'deleteTasks').and.returnValue(Promise.resolve());
await service.removeTagsFromAllTasks(['tag1']);
const saveArgs = archiveOldMock.save.calls.mostRecent().args;
const saveArgs = archiveDbAdapterMock.saveArchiveOld.calls.mostRecent().args;
const updatedTask = saveArgs[0].task.entities['task1'] as Task;
expect(updatedTask.tagIds).toEqual(['tag3']);
@ -759,8 +769,10 @@ describe('TaskArchiveService', () => {
]);
const oldArchive = createMockArchiveModel([]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
// Spy on deleteTasks to verify it's called with the right parameters
spyOn(service, 'deleteTasks').and.returnValue(Promise.resolve());
@ -784,19 +796,14 @@ describe('TaskArchiveService', () => {
const task = createMockTask('task1', { title: 'Original Title' });
const archive = createMockArchiveModel([task]);
archiveYoungMock.load.and.returnValue(Promise.resolve(archive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(Promise.resolve(archive));
// Test the updateTask method which uses _reduceForArchive internally
await service.updateTask('task1', { title: 'Updated Title' });
expect(archiveYoungMock.save).toHaveBeenCalled();
const saveCall = archiveYoungMock.save.calls.mostRecent();
expect(archiveDbAdapterMock.saveArchiveYoung).toHaveBeenCalled();
const saveCall = archiveDbAdapterMock.saveArchiveYoung.calls.mostRecent();
expect(saveCall.args[0].task).toBeDefined();
// isIgnoreDBLock is undefined by default
expect(saveCall.args[1]).toEqual({
isUpdateRevAndLastUpdate: true,
isIgnoreDBLock: undefined,
});
});
it('should handle deleteTasks action through _reduceForArchive', async () => {
@ -804,13 +811,15 @@ describe('TaskArchiveService', () => {
const task2 = createMockTask('task2');
const archive = createMockArchiveModel([task1, task2]);
archiveYoungMock.load.and.returnValue(Promise.resolve(archive));
archiveOldMock.load.and.returnValue(Promise.resolve(createMockArchiveModel([])));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(Promise.resolve(archive));
archiveDbAdapterMock.loadArchiveOld.and.returnValue(
Promise.resolve(createMockArchiveModel([])),
);
await service.deleteTasks(['task1']);
expect(archiveYoungMock.save).toHaveBeenCalled();
const saveCall = archiveYoungMock.save.calls.mostRecent();
expect(archiveDbAdapterMock.saveArchiveYoung).toHaveBeenCalled();
const saveCall = archiveDbAdapterMock.saveArchiveYoung.calls.mostRecent();
const savedArchive = saveCall.args[0];
expect(savedArchive.task).toBeDefined();
// The actual deletion is handled by the reducer
@ -824,8 +833,10 @@ describe('TaskArchiveService', () => {
});
const archive = createMockArchiveModel([task]);
archiveYoungMock.load.and.returnValue(Promise.resolve(archive));
archiveOldMock.load.and.returnValue(Promise.resolve(createMockArchiveModel([])));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(Promise.resolve(archive));
archiveDbAdapterMock.loadArchiveOld.and.returnValue(
Promise.resolve(createMockArchiveModel([])),
);
await service.roundTimeSpent({
day: '2024-01-01',
@ -835,8 +846,8 @@ describe('TaskArchiveService', () => {
projectId: 'project1',
});
expect(archiveYoungMock.save).toHaveBeenCalled();
const saveCall = archiveYoungMock.save.calls.mostRecent();
expect(archiveDbAdapterMock.saveArchiveYoung).toHaveBeenCalled();
const saveCall = archiveDbAdapterMock.saveArchiveYoung.calls.mostRecent();
expect(saveCall.args[0].task).toBeDefined();
});
});
@ -847,8 +858,10 @@ describe('TaskArchiveService', () => {
const task2 = createMockTask('task2', { title: 'Task 2', isDone: false });
const archive = createMockArchiveModel([task1, task2]);
archiveYoungMock.load.and.returnValue(Promise.resolve(archive));
archiveOldMock.load.and.returnValue(Promise.resolve(createMockArchiveModel([])));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(Promise.resolve(archive));
archiveDbAdapterMock.loadArchiveOld.and.returnValue(
Promise.resolve(createMockArchiveModel([])),
);
const updates: Update<Task>[] = [
{ id: 'task1', changes: { title: 'Updated Task 1', isDone: true } },
@ -857,8 +870,8 @@ describe('TaskArchiveService', () => {
await service.updateTasks(updates);
expect(archiveYoungMock.save).toHaveBeenCalled();
const saveCall = archiveYoungMock.save.calls.mostRecent();
expect(archiveDbAdapterMock.saveArchiveYoung).toHaveBeenCalled();
const saveCall = archiveDbAdapterMock.saveArchiveYoung.calls.mostRecent();
const savedArchive = saveCall.args[0];
expect(savedArchive.task).toBeDefined();
// Each update is applied iteratively through _reduceForArchive
@ -871,8 +884,10 @@ describe('TaskArchiveService', () => {
const youngArchive = createMockArchiveModel([youngTask]);
const oldArchive = createMockArchiveModel([oldTask]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
const updates: Update<Task>[] = [
{ id: 'young1', changes: { title: 'Updated Young' } },
@ -881,15 +896,15 @@ describe('TaskArchiveService', () => {
await service.updateTasks(updates);
expect(archiveYoungMock.save).toHaveBeenCalled();
expect(archiveOldMock.save).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveYoung).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveOld).toHaveBeenCalled();
// Verify young archive save
const youngSaveCall = archiveYoungMock.save.calls.mostRecent();
const youngSaveCall = archiveDbAdapterMock.saveArchiveYoung.calls.mostRecent();
expect(youngSaveCall.args[0].task).toBeDefined();
// Verify old archive save
const oldSaveCall = archiveOldMock.save.calls.mostRecent();
const oldSaveCall = archiveDbAdapterMock.saveArchiveOld.calls.mostRecent();
expect(oldSaveCall.args[0].task).toBeDefined();
});
});
@ -904,17 +919,19 @@ describe('TaskArchiveService', () => {
const youngArchive = createMockArchiveModel(tasks.slice(0, 2));
const oldArchive = createMockArchiveModel([tasks[2]]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
await service.deleteTasks(['task1', 'task3']);
// Both archives should be saved with properly reduced state
expect(archiveYoungMock.save).toHaveBeenCalled();
expect(archiveOldMock.save).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveYoung).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveOld).toHaveBeenCalled();
const youngSave = archiveYoungMock.save.calls.mostRecent();
const oldSave = archiveOldMock.save.calls.mostRecent();
const youngSave = archiveDbAdapterMock.saveArchiveYoung.calls.mostRecent();
const oldSave = archiveDbAdapterMock.saveArchiveOld.calls.mostRecent();
expect(youngSave.args[0].task).toBeDefined();
expect(oldSave.args[0].task).toBeDefined();
@ -933,8 +950,10 @@ describe('TaskArchiveService', () => {
const youngArchive = createMockArchiveModel([youngTask]);
const oldArchive = createMockArchiveModel([oldTask]);
archiveYoungMock.load.and.returnValue(Promise.resolve(youngArchive));
archiveOldMock.load.and.returnValue(Promise.resolve(oldArchive));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(
Promise.resolve(youngArchive),
);
archiveDbAdapterMock.loadArchiveOld.and.returnValue(Promise.resolve(oldArchive));
await service.roundTimeSpent({
day: '2024-01-01',
@ -943,12 +962,12 @@ describe('TaskArchiveService', () => {
isRoundUp: false,
});
expect(archiveYoungMock.save).toHaveBeenCalled();
expect(archiveOldMock.save).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveYoung).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveOld).toHaveBeenCalled();
// Both saves should have properly reduced task state
const youngSave = archiveYoungMock.save.calls.mostRecent();
const oldSave = archiveOldMock.save.calls.mostRecent();
const youngSave = archiveDbAdapterMock.saveArchiveYoung.calls.mostRecent();
const oldSave = archiveDbAdapterMock.saveArchiveOld.calls.mostRecent();
expect(youngSave.args[0].task).toBeDefined();
expect(oldSave.args[0].task).toBeDefined();
@ -958,8 +977,10 @@ describe('TaskArchiveService', () => {
const task = createMockTask('task1', { tagIds: ['tag1', 'tag2'] });
const archive = createMockArchiveModel([task]);
archiveYoungMock.load.and.returnValue(Promise.resolve(archive));
archiveOldMock.load.and.returnValue(Promise.resolve(createMockArchiveModel([])));
archiveDbAdapterMock.loadArchiveYoung.and.returnValue(Promise.resolve(archive));
archiveDbAdapterMock.loadArchiveOld.and.returnValue(
Promise.resolve(createMockArchiveModel([])),
);
// Mock load to prevent orphan deletion logic
spyOn(service, 'load').and.returnValue(
@ -972,8 +993,8 @@ describe('TaskArchiveService', () => {
await service.removeTagsFromAllTasks(['tag1']);
// Both archives should be updated through _execActionBoth
expect(archiveYoungMock.save).toHaveBeenCalled();
expect(archiveOldMock.save).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveYoung).toHaveBeenCalled();
expect(archiveDbAdapterMock.saveArchiveOld).toHaveBeenCalled();
});
});
});

View file

@ -5,19 +5,25 @@ import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { TASK_FEATURE_NAME, taskReducer } from '../tasks/store/task.reducer';
import { taskSharedCrudMetaReducer } from '../../root-store/meta/task-shared-meta-reducers/task-shared-crud.reducer';
import { tagSharedMetaReducer } from '../../root-store/meta/task-shared-meta-reducers/tag-shared.reducer';
import { PfapiService } from '../../pfapi/pfapi.service';
import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service';
import { Task, TaskArchive, TaskState } from '../tasks/task.model';
import { RoundTimeOption } from '../project/project.model';
import { Update } from '@ngrx/entity';
import { ArchiveModel } from './time-tracking.model';
import { ModelCfgToModelCtrl } from '../../pfapi/api';
import { PfapiAllModelCfg } from '../../pfapi/pfapi-config';
import { ArchiveModel } from './archive.model';
import { initialTimeTrackingState } from '../time-tracking/store/time-tracking.reducer';
import { RootState } from '../../root-store/root-state';
import { PROJECT_FEATURE_NAME } from '../project/store/project.reducer';
import { TAG_FEATURE_NAME } from '../tag/store/tag.reducer';
import { WORK_CONTEXT_FEATURE_NAME } from '../work-context/store/work-context.selectors';
import { plannerFeatureKey } from '../planner/store/planner.reducer';
// Default empty archive
const DEFAULT_ARCHIVE: ArchiveModel = {
task: { ids: [], entities: {} },
timeTracking: initialTimeTrackingState,
lastTimeTrackingFlush: 0,
};
// Create a minimal RootState with the archive task state
// Other feature states are empty as they're not needed for task updates
const FAKE_ROOT_STATE: RootState = {
@ -40,16 +46,13 @@ type TaskArchiveAction =
providedIn: 'root',
})
export class TaskArchiveService {
// Use lazy injection to break circular dependency:
// PfapiService -> Pfapi -> OperationLogSyncService -> OperationApplierService
// -> ArchiveOperationHandler -> TaskArchiveService -> PfapiService
private _injector = inject(Injector);
private _pfapiService?: PfapiService;
private get pfapiService(): PfapiService {
if (!this._pfapiService) {
this._pfapiService = this._injector.get(PfapiService);
private _archiveDbAdapter?: ArchiveDbAdapter;
private get archiveDbAdapter(): ArchiveDbAdapter {
if (!this._archiveDbAdapter) {
this._archiveDbAdapter = this._injector.get(ArchiveDbAdapter);
}
return this._pfapiService;
return this._archiveDbAdapter;
}
private _store?: Store;
@ -77,7 +80,8 @@ export class TaskArchiveService {
constructor() {}
async loadYoung(): Promise<TaskArchive> {
const archiveYoung = await this.pfapiService.m.archiveYoung.load();
const archiveYoung =
(await this.archiveDbAdapter.loadArchiveYoung()) || DEFAULT_ARCHIVE;
return {
ids: archiveYoung.task.ids,
entities: archiveYoung.task.entities,
@ -87,25 +91,29 @@ export class TaskArchiveService {
async load(): Promise<TaskArchive> {
// NOTE: these are already saved in memory to speed up things
const [archiveYoung, archiveOld] = await Promise.all([
this.pfapiService.m.archiveYoung.load(),
this.pfapiService.m.archiveOld.load(),
this.archiveDbAdapter.loadArchiveYoung(),
this.archiveDbAdapter.loadArchiveOld(),
]);
const young = archiveYoung || DEFAULT_ARCHIVE;
const old = archiveOld || DEFAULT_ARCHIVE;
return {
ids: [...archiveYoung.task.ids, ...archiveOld.task.ids],
ids: [...young.task.ids, ...old.task.ids],
entities: {
...archiveYoung.task.entities,
...archiveOld.task.entities,
...young.task.entities,
...old.task.entities,
},
};
}
async getById(id: string): Promise<Task> {
const archiveYoung = await this.pfapiService.m.archiveYoung.load();
const archiveYoung =
(await this.archiveDbAdapter.loadArchiveYoung()) || DEFAULT_ARCHIVE;
if (archiveYoung.task.entities[id]) {
return archiveYoung.task.entities[id];
}
const archiveOld = await this.pfapiService.m.archiveOld.load();
const archiveOld = (await this.archiveDbAdapter.loadArchiveOld()) || DEFAULT_ARCHIVE;
if (archiveOld.task.entities[id]) {
return archiveOld.task.entities[id];
}
@ -116,11 +124,12 @@ export class TaskArchiveService {
* Checks if a task exists in either archive (young or old).
*/
async hasTask(id: string): Promise<boolean> {
const archiveYoung = await this.pfapiService.m.archiveYoung.load();
const archiveYoung =
(await this.archiveDbAdapter.loadArchiveYoung()) || DEFAULT_ARCHIVE;
if (archiveYoung.task.entities[id]) {
return true;
}
const archiveOld = await this.pfapiService.m.archiveOld.load();
const archiveOld = (await this.archiveDbAdapter.loadArchiveOld()) || DEFAULT_ARCHIVE;
return !!archiveOld.task.entities[id];
}
@ -128,7 +137,8 @@ export class TaskArchiveService {
taskIdsToDelete: string[],
options?: { isIgnoreDBLock?: boolean },
): Promise<void> {
const archiveYoung = await this.pfapiService.m.archiveYoung.load();
const archiveYoung =
(await this.archiveDbAdapter.loadArchiveYoung()) || DEFAULT_ARCHIVE;
const toDeleteInArchiveYoung = taskIdsToDelete.filter(
(id) => !!archiveYoung.task.entities[id],
);
@ -138,20 +148,15 @@ export class TaskArchiveService {
archiveYoung,
TaskSharedActions.deleteTasks({ taskIds: toDeleteInArchiveYoung }),
);
await this.pfapiService.m.archiveYoung.save(
{
...archiveYoung,
task: newTaskState,
},
{
isUpdateRevAndLastUpdate: true,
isIgnoreDBLock: options?.isIgnoreDBLock,
},
);
await this.archiveDbAdapter.saveArchiveYoung({
...archiveYoung,
task: newTaskState,
});
}
if (toDeleteInArchiveYoung.length < taskIdsToDelete.length) {
const archiveOld = await this.pfapiService.m.archiveOld.load();
const archiveOld =
(await this.archiveDbAdapter.loadArchiveOld()) || DEFAULT_ARCHIVE;
const toDeleteInArchiveOld = taskIdsToDelete.filter(
(id) => !!archiveOld.task.entities[id],
);
@ -159,16 +164,10 @@ export class TaskArchiveService {
archiveOld,
TaskSharedActions.deleteTasks({ taskIds: toDeleteInArchiveOld }),
);
await this.pfapiService.m.archiveOld.save(
{
...archiveOld,
task: newTaskStateArchiveOld,
},
{
isUpdateRevAndLastUpdate: true,
isIgnoreDBLock: options?.isIgnoreDBLock,
},
);
await this.archiveDbAdapter.saveArchiveOld({
...archiveOld,
task: newTaskStateArchiveOld,
});
}
}
@ -177,13 +176,13 @@ export class TaskArchiveService {
changedFields: Partial<Task>,
options?: { isSkipDispatch?: boolean; isIgnoreDBLock?: boolean },
): Promise<void> {
const archiveYoung = await this.pfapiService.m.archiveYoung.load();
const archiveYoung =
(await this.archiveDbAdapter.loadArchiveYoung()) || DEFAULT_ARCHIVE;
if (archiveYoung.task.entities[id]) {
await this._execAction(
'archiveYoung',
archiveYoung,
TaskSharedActions.updateTask({ task: { id, changes: changedFields } }),
options?.isIgnoreDBLock,
);
// Dispatch persistent action for sync (skip for remote handler calls)
if (!options?.isSkipDispatch) {
@ -193,13 +192,12 @@ export class TaskArchiveService {
}
return;
}
const archiveOld = await this.pfapiService.m.archiveOld.load();
const archiveOld = (await this.archiveDbAdapter.loadArchiveOld()) || DEFAULT_ARCHIVE;
if (archiveOld.task.entities[id]) {
await this._execAction(
'archiveOld',
archiveOld,
TaskSharedActions.updateTask({ task: { id, changes: changedFields } }),
options?.isIgnoreDBLock,
);
// Dispatch persistent action for sync (skip for remote handler calls)
if (!options?.isSkipDispatch) {
@ -217,7 +215,8 @@ export class TaskArchiveService {
options?: { isSkipDispatch?: boolean; isIgnoreDBLock?: boolean },
): Promise<void> {
const allUpdates = updates.map((upd) => TaskSharedActions.updateTask({ task: upd }));
const archiveYoung = await this.pfapiService.m.archiveYoung.load();
const archiveYoung =
(await this.archiveDbAdapter.loadArchiveYoung()) || DEFAULT_ARCHIVE;
const updatesYoung = allUpdates.filter(
(upd) => !!archiveYoung.task.entities[upd.task.id],
);
@ -228,17 +227,15 @@ export class TaskArchiveService {
currentArchiveYoung = { ...currentArchiveYoung, task: newTaskState };
}
const newTaskStateArchiveYoung = currentArchiveYoung.task;
await this.pfapiService.m.archiveYoung.save(
{
...archiveYoung,
task: newTaskStateArchiveYoung,
},
{ isUpdateRevAndLastUpdate: true, isIgnoreDBLock: options?.isIgnoreDBLock },
);
await this.archiveDbAdapter.saveArchiveYoung({
...archiveYoung,
task: newTaskStateArchiveYoung,
});
}
if (updatesYoung.length < updates.length) {
const archiveOld = await this.pfapiService.m.archiveOld.load();
const archiveOld =
(await this.archiveDbAdapter.loadArchiveOld()) || DEFAULT_ARCHIVE;
const updatesOld = allUpdates.filter(
(upd) => !!archiveOld.task.entities[upd.task.id],
);
@ -248,13 +245,10 @@ export class TaskArchiveService {
currentArchiveOld = { ...currentArchiveOld, task: newTaskState };
}
const newTaskStateArchiveOld = currentArchiveOld.task;
await this.pfapiService.m.archiveOld.save(
{
...archiveOld,
task: newTaskStateArchiveOld,
},
{ isUpdateRevAndLastUpdate: true, isIgnoreDBLock: options?.isIgnoreDBLock },
);
await this.archiveDbAdapter.saveArchiveOld({
...archiveOld,
task: newTaskStateArchiveOld,
});
}
// Dispatch batch action for sync (skip for remote handler calls)
@ -291,9 +285,6 @@ export class TaskArchiveService {
const taskArchiveState: TaskArchive = await this.load();
await this._execActionBoth(
TaskSharedActions.removeTagsForAllTasks({ tagIdsToRemove }),
{
isIgnoreDBLock: options?.isIgnoreDBLock,
},
);
const isOrphanedParentTask = (t: Task): boolean =>
@ -391,7 +382,8 @@ export class TaskArchiveService {
isRoundUp: boolean;
projectId?: string | null;
}): Promise<void> {
const archiveYoung = await this.pfapiService.m.archiveYoung.load();
const archiveYoung =
(await this.archiveDbAdapter.loadArchiveYoung()) || DEFAULT_ARCHIVE;
const taskIdsInArchiveYoung = taskIds.filter(
(id) => !!archiveYoung.task.entities[id],
);
@ -406,16 +398,14 @@ export class TaskArchiveService {
projectId,
}),
);
await this.pfapiService.m.archiveYoung.save(
{
...archiveYoung,
task: newTaskState,
},
{ isUpdateRevAndLastUpdate: true },
);
await this.archiveDbAdapter.saveArchiveYoung({
...archiveYoung,
task: newTaskState,
});
}
if (taskIdsInArchiveYoung.length < taskIds.length) {
const archiveOld = await this.pfapiService.m.archiveOld.load();
const archiveOld =
(await this.archiveDbAdapter.loadArchiveOld()) || DEFAULT_ARCHIVE;
const taskIdsInArchiveOld = taskIds.filter((id) => !!archiveOld.task.entities[id]);
if (taskIdsInArchiveOld.length > 0) {
const newTaskStateArchiveOld = this._reduceForArchive(
@ -428,13 +418,10 @@ export class TaskArchiveService {
projectId,
}),
);
await this.pfapiService.m.archiveOld.save(
{
...archiveOld,
task: newTaskStateArchiveOld,
},
{ isUpdateRevAndLastUpdate: true },
);
await this.archiveDbAdapter.saveArchiveOld({
...archiveOld,
task: newTaskStateArchiveOld,
});
}
}
}
@ -442,49 +429,40 @@ export class TaskArchiveService {
// -----------------------------------------
private async _execAction(
target: Extract<
keyof ModelCfgToModelCtrl<PfapiAllModelCfg>,
'archiveYoung' | 'archiveOld'
>,
target: 'archiveYoung' | 'archiveOld',
archiveBefore: ArchiveModel,
action: TaskArchiveAction,
isIgnoreDBLock?: boolean,
): Promise<void> {
const newTaskState = this._reduceForArchive(archiveBefore, action);
await this.pfapiService.m[target].save(
{
if (target === 'archiveYoung') {
await this.archiveDbAdapter.saveArchiveYoung({
...archiveBefore,
task: newTaskState,
},
{ isUpdateRevAndLastUpdate: true, isIgnoreDBLock },
);
});
} else {
await this.archiveDbAdapter.saveArchiveOld({
...archiveBefore,
task: newTaskState,
});
}
}
private async _execActionBoth(
action: TaskArchiveAction,
options?: { isUpdateRevAndLastUpdate?: boolean; isIgnoreDBLock?: boolean },
): Promise<void> {
const isUpdateRevAndLastUpdate = options?.isUpdateRevAndLastUpdate ?? true;
const archiveYoung = await this.pfapiService.m.archiveYoung.load();
private async _execActionBoth(action: TaskArchiveAction): Promise<void> {
const archiveYoung =
(await this.archiveDbAdapter.loadArchiveYoung()) || DEFAULT_ARCHIVE;
const newTaskState = this._reduceForArchive(archiveYoung, action);
const archiveOld = await this.pfapiService.m.archiveOld.load();
const archiveOld = (await this.archiveDbAdapter.loadArchiveOld()) || DEFAULT_ARCHIVE;
const newTaskStateArchiveOld = this._reduceForArchive(archiveOld, action);
await this.pfapiService.m.archiveYoung.save(
{
...archiveYoung,
task: newTaskState,
},
{ isUpdateRevAndLastUpdate, isIgnoreDBLock: options?.isIgnoreDBLock },
);
await this.pfapiService.m.archiveOld.save(
{
...archiveOld,
task: newTaskStateArchiveOld,
},
{ isUpdateRevAndLastUpdate, isIgnoreDBLock: options?.isIgnoreDBLock },
);
await this.archiveDbAdapter.saveArchiveYoung({
...archiveYoung,
task: newTaskState,
});
await this.archiveDbAdapter.saveArchiveOld({
...archiveOld,
task: newTaskStateArchiveOld,
});
}
private _reduceForArchive(
@ -503,28 +481,4 @@ export class TaskArchiveService {
// Extract and return the updated task state
return updatedRootState[TASK_FEATURE_NAME];
}
// more beautiful but less efficient
// private async _partitionTasksByArchive<T>(
// ids: string[],
// mapper: (id: string, archive: ArchiveModel) => T,
// ): Promise<{ young: T[]; old: T[] }> {
// const [archiveYoung, archiveOld] = await Promise.all([
// this.pfapiService.m.archive.load(),
// this.pfapiService.m.archiveOld.load(),
// ]);
//
// const young: T[] = [];
// const old: T[] = [];
//
// ids.forEach((id) => {
// if (archiveYoung.task.entities[id]) {
// young.push(mapper(id, archiveYoung));
// } else if (archiveOld.task.entities[id]) {
// old.push(mapper(id, archiveOld));
// }
// });
//
// return { young, old };
// }
}

View file

@ -4,13 +4,13 @@ import {
sortTimeTrackingDataToArchiveYoung,
splitArchiveTasksByDoneOnThreshold,
} from './sort-data-to-flush';
import { ArchiveModel } from '../archive.model';
import {
ArchiveModel,
TimeTrackingState,
TTWorkContextData,
} from './time-tracking.model';
import { ImpossibleError } from '../../pfapi/api';
import { TaskCopy } from '../tasks/task.model';
} from '../../time-tracking/time-tracking.model';
import { ImpossibleError } from '../../../sync/sync-exports';
import { TaskCopy } from '../../tasks/task.model';
const BASE_TASK: TaskCopy = {
title: 'base task',

View file

@ -1,11 +1,11 @@
import { ArchiveModel } from '../archive.model';
import {
ArchiveModel,
TimeTrackingState,
TTWorkContextSessionMap,
} from './time-tracking.model';
import { ArchiveTask, TaskArchive } from '../tasks/task.model';
import { ImpossibleError } from '../../pfapi/api';
import { dirtyDeepCopy } from '../../util/dirtyDeepCopy';
} from '../../time-tracking/time-tracking.model';
import { ArchiveTask, TaskArchive } from '../../tasks/task.model';
import { ImpossibleError } from '../../../sync/sync-exports';
import { dirtyDeepCopy } from '../../../util/dirtyDeepCopy';
const TIME_TRACKING_CATEGORIES = ['project', 'tag'] as const;

View file

@ -4,7 +4,7 @@ import { ConfigFormSection, SyncConfig } from '../global-config.model';
import { LegacySyncProvider } from '../../../imex/sync/legacy-sync-provider.model';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { IS_ELECTRON } from '../../../app.constants';
import { fileSyncDroid, fileSyncElectron } from '../../../pfapi/pfapi-config';
import { fileSyncDroid, fileSyncElectron } from '../../../sync/model-config';
import { FormlyFieldConfig } from '@ngx-formly/core';
/**

View file

@ -2,14 +2,14 @@ import { globalConfigReducer, initialGlobalConfigState } from './global-config.r
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { GlobalConfigState } from '../global-config.model';
import { LegacySyncProvider } from '../../../imex/sync/legacy-sync-provider.model';
import { AppDataCompleteNew } from '../../../pfapi/pfapi-config';
import { AppDataComplete } from '../../../sync/model-config';
describe('GlobalConfigReducer', () => {
describe('loadAllData action', () => {
it('should return oldState when appDataComplete.globalConfig is falsy', () => {
const result = globalConfigReducer(
initialGlobalConfigState,
loadAllData({ appDataComplete: {} as AppDataCompleteNew }),
loadAllData({ appDataComplete: {} as AppDataComplete }),
);
expect(result).toBe(initialGlobalConfigState);
@ -27,7 +27,7 @@ describe('GlobalConfigReducer', () => {
const result = globalConfigReducer(
initialGlobalConfigState,
loadAllData({
appDataComplete: { globalConfig: newConfig } as AppDataCompleteNew,
appDataComplete: { globalConfig: newConfig } as AppDataComplete,
}),
);
@ -49,7 +49,7 @@ describe('GlobalConfigReducer', () => {
const result = globalConfigReducer(
oldState,
loadAllData({
appDataComplete: { globalConfig: snapshotConfig } as AppDataCompleteNew,
appDataComplete: { globalConfig: snapshotConfig } as AppDataComplete,
}),
);
@ -81,7 +81,7 @@ describe('GlobalConfigReducer', () => {
const result = globalConfigReducer(
oldState,
loadAllData({
appDataComplete: { globalConfig: syncedConfig } as AppDataCompleteNew,
appDataComplete: { globalConfig: syncedConfig } as AppDataComplete,
}),
);
@ -111,7 +111,7 @@ describe('GlobalConfigReducer', () => {
const result = globalConfigReducer(
oldState,
loadAllData({
appDataComplete: { globalConfig: syncedConfig } as AppDataCompleteNew,
appDataComplete: { globalConfig: syncedConfig } as AppDataComplete,
}),
);
@ -142,7 +142,7 @@ describe('GlobalConfigReducer', () => {
const result = globalConfigReducer(
oldState,
loadAllData({
appDataComplete: { globalConfig: syncedConfig } as AppDataCompleteNew,
appDataComplete: { globalConfig: syncedConfig } as AppDataComplete,
}),
);

View file

@ -9,7 +9,7 @@ import { toSignal } from '@angular/core/rxjs-interop';
import { WorklogService } from '../../worklog/worklog.service';
import { WorkContextService } from '../../work-context/work-context.service';
import { TaskService } from '../../tasks/task.service';
import { TaskArchiveService } from '../../time-tracking/task-archive.service';
import { TaskArchiveService } from '../../archive/task-archive.service';
import { defer, from } from 'rxjs';
import { first, map, switchMap } from 'rxjs/operators';
import { TranslatePipe } from '@ngx-translate/core';

View file

@ -21,7 +21,6 @@ import { isImageUrl, isImageUrlSimple } from '../../util/is-image-url';
import { DropPasteInput } from '../../core/drop-paste-input/drop-paste.model';
import { WorkContextService } from '../work-context/work-context.service';
import { WorkContextType } from '../work-context/work-context.model';
import { PfapiService } from '../../pfapi/pfapi.service';
import { isInputElement } from '../../util/dom-element';
@Injectable({
@ -29,7 +28,6 @@ import { isInputElement } from '../../util/dom-element';
})
export class NoteService {
private _store$ = inject<Store<any>>(Store);
private _pfapiService = inject(PfapiService);
private _workContextService = inject(WorkContextService);
notes$: Observable<Note[]> = this._store$.pipe(select(selectAllNotes));

View file

@ -5,16 +5,13 @@ import { ReminderService } from './reminder.service';
import { SnackService } from '../../core/snack/snack.service';
import { ImexViewService } from '../../imex/imex-meta/imex-view.service';
import { GlobalConfigService } from '../config/global-config.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { TaskWithReminder } from '../tasks/task.model';
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { selectAllTasksWithReminder } from '../tasks/store/task.selectors';
describe('ReminderService', () => {
let service: ReminderService;
let mockStore: jasmine.SpyObj<Store>;
let mockWorker: jasmine.SpyObj<Worker>;
let mockPfapiService: any;
let tasksWithReminderSubject: BehaviorSubject<TaskWithReminder[]>;
let isDataImportInProgressSubject: BehaviorSubject<boolean>;
@ -58,17 +55,6 @@ describe('ReminderService', () => {
reminder: { disableReminders: false } as any,
});
mockPfapiService = {
pf: {
m: {
reminders: {
load: jasmine.createSpy('load').and.returnValue(Promise.resolve([])),
save: jasmine.createSpy('save').and.returnValue(Promise.resolve()),
},
},
},
};
TestBed.configureTestingModule({
providers: [
ReminderService,
@ -76,7 +62,6 @@ describe('ReminderService', () => {
{ provide: SnackService, useValue: snackServiceSpy },
{ provide: ImexViewService, useValue: imexViewServiceSpy },
{ provide: GlobalConfigService, useValue: globalConfigServiceSpy },
{ provide: PfapiService, useValue: mockPfapiService },
],
});
@ -276,83 +261,23 @@ describe('ReminderService', () => {
});
describe('legacy reminder migration', () => {
it('should not dispatch actions when no legacy reminders exist', async () => {
mockPfapiService.pf.m.reminders.load.and.returnValue(Promise.resolve([]));
// Note: Legacy reminder migration tests were removed because mocking the idb.openDB
// function is not possible (it's not writable). The migration functionality works
// by reading from the legacy 'pf' IndexedDB database, which doesn't exist in tests.
// The migration silently fails (errors are caught) which is the expected behavior
// when there's no legacy data to migrate.
it('should handle missing legacy database gracefully', async () => {
// Legacy migration should silently fail when 'pf' database doesn't exist
// (which is the case in tests). No migration actions should be dispatched.
service.init();
await Promise.resolve(); // Allow async migration to complete
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
it('should migrate TASK reminders to task.remindAt', async () => {
const legacyReminders = [
{
id: 'reminder1',
remindAt: 1000,
title: 'Task Reminder',
type: 'TASK',
relatedId: 'task1',
},
];
mockPfapiService.pf.m.reminders.load.and.returnValue(
Promise.resolve(legacyReminders),
const dispatchCalls = mockStore.dispatch.calls.allArgs();
const migrationCalls = dispatchCalls.filter(
(args) => (args[0] as any).type === '[Task Shared] reScheduleTaskWithTime',
);
service.init();
await Promise.resolve(); // Allow async migration to complete
expect(mockStore.dispatch).toHaveBeenCalledWith(
TaskSharedActions.reScheduleTaskWithTime({
task: { id: 'task1', title: 'Task Reminder' } as TaskWithReminder,
dueWithTime: 1000,
remindAt: 1000,
isMoveToBacklog: false,
}),
);
});
it('should skip NOTE reminders during migration', async () => {
const legacyReminders = [
{
id: 'reminder1',
remindAt: 1000,
title: 'Note Reminder',
type: 'NOTE',
relatedId: 'note1',
},
];
mockPfapiService.pf.m.reminders.load.and.returnValue(
Promise.resolve(legacyReminders),
);
service.init();
await Promise.resolve(); // Allow async migration to complete
// Should not dispatch for NOTE reminders
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
it('should clear legacy reminders after migration', async () => {
const legacyReminders = [
{
id: 'reminder1',
remindAt: 1000,
title: 'Task Reminder',
type: 'TASK',
relatedId: 'task1',
},
];
mockPfapiService.pf.m.reminders.load.and.returnValue(
Promise.resolve(legacyReminders),
);
service.init();
await Promise.resolve(); // Allow async migration to complete
expect(mockPfapiService.pf.m.reminders.save).toHaveBeenCalledWith([], {
isUpdateRevAndLastUpdate: false,
});
expect(migrationCalls.length).toBe(0);
});
});

View file

@ -10,8 +10,8 @@ import { GlobalConfigService } from '../config/global-config.service';
import { Store } from '@ngrx/store';
import { selectAllTasksWithReminder } from '../tasks/store/task.selectors';
import { TaskWithReminder, TaskWithReminderData } from '../tasks/task.model';
import { PfapiService } from '../../pfapi/pfapi.service';
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
interface WorkerReminder {
id: string;
@ -36,7 +36,7 @@ export class ReminderService {
private readonly _imexMetaService = inject(ImexViewService);
private readonly _globalConfigService = inject(GlobalConfigService);
private readonly _store = inject(Store);
private readonly _pfapiService = inject(PfapiService);
private readonly _legacyPfDb = inject(LegacyPfDbService);
private _onRemindersActive$: Subject<TaskWithReminderData[]> = new Subject<
TaskWithReminderData[]
@ -95,9 +95,7 @@ export class ReminderService {
private async _migrateLegacyReminders(): Promise<void> {
try {
const legacyReminders = (await this._pfapiService.pf.m.reminders.load()) as
| LegacyReminder[]
| null;
const legacyReminders = await this._legacyPfDb.load<LegacyReminder[]>('reminders');
if (!legacyReminders || legacyReminders.length === 0) {
Log.log('ReminderService: No legacy reminders to migrate');
@ -136,9 +134,7 @@ export class ReminderService {
}
// Clear legacy reminders after migration
await this._pfapiService.pf.m.reminders.save([], {
isUpdateRevAndLastUpdate: false,
});
await this._legacyPfDb.save('reminders', []);
Log.log(
`ReminderService: Migration complete - ${migratedCount} migrated, ${skippedNotes} NOTE reminders skipped`,

View file

@ -6,7 +6,7 @@ import { TaskRepeatCfgEffects } from './task-repeat-cfg.effects';
import { TaskService } from '../../tasks/task.service';
import { TaskRepeatCfgService } from '../task-repeat-cfg.service';
import { MatDialog } from '@angular/material/dialog';
import { TaskArchiveService } from '../../time-tracking/task-archive.service';
import { TaskArchiveService } from '../../archive/task-archive.service';
import { addTaskRepeatCfgToTask, updateTaskRepeatCfg } from './task-repeat-cfg.actions';
import {
DEFAULT_TASK,

View file

@ -26,7 +26,7 @@ import { dateStrToUtcDate } from '../../../util/date-str-to-utc-date';
import { getDateTimeFromClockString } from '../../../util/get-date-time-from-clock-string';
import { getDbDateStr } from '../../../util/get-db-date-str';
import { isToday } from '../../../util/is-today.util';
import { TaskArchiveService } from '../../time-tracking/task-archive.service';
import { TaskArchiveService } from '../../archive/task-archive.service';
import { Log } from '../../../core/log';
import {
addSubTask,

View file

@ -5,8 +5,8 @@ import { WorkContextService } from '../work-context/work-context.service';
import { GlobalTrackingIntervalService } from '../../core/global-tracking-interval/global-tracking-interval.service';
import { DateService } from '../../core/date/date.service';
import { Router } from '@angular/router';
import { ArchiveService } from '../time-tracking/archive.service';
import { TaskArchiveService } from '../time-tracking/task-archive.service';
import { ArchiveService } from '../archive/archive.service';
import { TaskArchiveService } from '../archive/task-archive.service';
import { GlobalConfigService } from '../config/global-config.service';
import { TaskFocusService } from './task-focus.service';
import { ImexViewService } from '../../imex/imex-meta/imex-view.service';

View file

@ -90,8 +90,8 @@ import {
syncTimeTracking,
} from '../time-tracking/store/time-tracking.actions';
import { selectTimeTrackingState } from '../time-tracking/store/time-tracking.selectors';
import { ArchiveService } from '../time-tracking/archive.service';
import { TaskArchiveService } from '../time-tracking/task-archive.service';
import { ArchiveService } from '../archive/archive.service';
import { TaskArchiveService } from '../archive/task-archive.service';
import { TODAY_TAG } from '../tag/tag.const';
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { getDbDateStr } from '../../util/get-db-date-str';

View file

@ -6,7 +6,7 @@ import {
syncTimeTracking,
} from './time-tracking.actions';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { AppDataCompleteNew } from '../../../pfapi/pfapi-config';
import { AppDataComplete } from '../../../sync/model-config';
import { TaskCopy } from '../../tasks/task.model';
import { WorkContextType } from '../../work-context/work-context.model';
@ -18,12 +18,12 @@ describe('TimeTracking Reducer', () => {
});
it('should load all data', () => {
const appDataComplete: AppDataCompleteNew = {
const appDataComplete: AppDataComplete = {
timeTracking: {
project: { '1': { '2023-01-01': { s: 1, e: 2, b: 3, bt: 4 } } },
tag: { '2': { '2023-01-02': { s: 5, e: 6, b: 7, bt: 8 } } },
},
} as Partial<AppDataCompleteNew> as AppDataCompleteNew;
} as Partial<AppDataComplete> as AppDataComplete;
const action = loadAllData({ appDataComplete });
const result = timeTrackingReducer(initialTimeTrackingState, action);
expect(result).toEqual(appDataComplete.timeTracking);

View file

@ -6,7 +6,7 @@ import {
import { createFeature, createReducer, on } from '@ngrx/store';
import { TimeTrackingState } from '../time-tracking.model';
import { loadAllData } from '../../../root-store/meta/load-all-data.action';
import { AppDataCompleteNew } from '../../../pfapi/pfapi-config';
import { AppDataComplete } from '../../../sync/model-config';
import { roundTsToMinutes } from '../../../util/round-ts-to-minutes';
import { TODAY_TAG } from '../../tag/tag.const';
@ -23,8 +23,8 @@ export const timeTrackingReducer = createReducer(
initialTimeTrackingState,
on(loadAllData, (state, { appDataComplete }) =>
(appDataComplete as AppDataCompleteNew).timeTracking
? (appDataComplete as AppDataCompleteNew).timeTracking
(appDataComplete as AppDataComplete).timeTracking
? (appDataComplete as AppDataComplete).timeTracking
: state,
),
on(TimeTrackingActions.updateWholeState, (state, { newState }) => newState),

View file

@ -1,5 +1,7 @@
// Base mapped types with clearer names
import { TaskArchive } from '../tasks/task.model';
// Re-export ArchiveModel for backward compatibility
export { ArchiveModel } from '../archive/archive.model';
export type TTModelId = string;
export type TTDate = string;
@ -55,14 +57,6 @@ export interface TimeTrackingState {
// somehow can't be optional for ngrx
}
// Archive model
export interface ArchiveModel {
// should not be written apart from flushing!
timeTracking: TimeTrackingState;
task: TaskArchive;
lastTimeTrackingFlush: number;
}
export const isWorkContextData = (obj: unknown): obj is TTWorkContextData =>
typeof obj === 'object' &&
obj !== null &&

View file

@ -5,19 +5,20 @@ import { first, map, shareReplay, startWith, switchMap } from 'rxjs/operators';
import { mergeTimeTrackingStates } from './merge-time-tracking-states';
import { Store } from '@ngrx/store';
import { selectTimeTrackingState } from './store/time-tracking.selectors';
import { PfapiService } from '../../pfapi/pfapi.service';
import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service';
import { WorkContextType, WorkStartEnd } from '../work-context/work-context.model';
import { ImpossibleError } from '../../pfapi/api';
import { ImpossibleError } from '../../sync/sync-exports';
import { toLegacyWorkStartEndMaps } from './to-legacy-work-start-end-maps';
import { TimeTrackingActions } from './store/time-tracking.actions';
import { Log } from '../../core/log';
import { initialTimeTrackingState } from './store/time-tracking.reducer';
@Injectable({
providedIn: 'root',
})
export class TimeTrackingService {
private _store = inject(Store);
private _pfapiService = inject(PfapiService);
private _archiveDbAdapter = inject(ArchiveDbAdapter);
private _archiveYoungUpdateTrigger$ = new Subject();
private _archiveOldUpdateTrigger$ = new Subject();
@ -26,7 +27,8 @@ export class TimeTrackingService {
archiveYoung$: Observable<TimeTrackingState> = this._archiveYoungUpdateTrigger$.pipe(
startWith(null),
switchMap(async () => {
return (await this._pfapiService.m.archiveYoung.load()).timeTracking;
const archive = await this._archiveDbAdapter.loadArchiveYoung();
return archive?.timeTracking || initialTimeTrackingState;
}),
shareReplay(1),
);
@ -34,7 +36,8 @@ export class TimeTrackingService {
archiveOld$: Observable<TimeTrackingState> = this._archiveOldUpdateTrigger$.pipe(
startWith(null),
switchMap(async () => {
return (await this._pfapiService.m.archiveOld.load()).timeTracking;
const archive = await this._archiveDbAdapter.loadArchiveOld();
return archive?.timeTracking || initialTimeTrackingState;
}),
shareReplay(1),
);
@ -70,8 +73,8 @@ export class TimeTrackingService {
async cleanupDataEverywhereForProject(projectId: string): Promise<void> {
const current = await this.current$.pipe(first()).toPromise();
const archiveYoung = await this._pfapiService.m.archiveYoung.load();
const archiveOld = await this._pfapiService.m.archiveOld.load();
const archiveYoung = await this._archiveDbAdapter.loadArchiveYoung();
const archiveOld = await this._archiveDbAdapter.loadArchiveOld();
Log.log({ current, archiveYoung, archiveOld });
@ -87,19 +90,15 @@ export class TimeTrackingService {
}),
);
}
if (projectId in archiveYoung.timeTracking.project) {
if (archiveYoung && projectId in archiveYoung.timeTracking.project) {
delete archiveYoung.timeTracking.project[projectId];
await this._pfapiService.m.archiveYoung.save(archiveYoung, {
isUpdateRevAndLastUpdate: true,
});
await this._archiveDbAdapter.saveArchiveYoung(archiveYoung);
this._archiveYoungUpdateTrigger$.next(undefined);
}
if (projectId in archiveOld.timeTracking.project) {
if (archiveOld && projectId in archiveOld.timeTracking.project) {
delete archiveOld.timeTracking.project[projectId];
await this._pfapiService.m.archiveOld.save(archiveOld, {
isUpdateRevAndLastUpdate: true,
});
await this._archiveDbAdapter.saveArchiveOld(archiveOld);
this._archiveOldUpdateTrigger$.next(undefined);
}
}
@ -110,8 +109,8 @@ export class TimeTrackingService {
*/
async cleanupDataEverywhereForTag(tagId: string): Promise<void> {
const current = await this.current$.pipe(first()).toPromise();
const archiveYoung = await this._pfapiService.m.archiveYoung.load();
const archiveOld = await this._pfapiService.m.archiveOld.load();
const archiveYoung = await this._archiveDbAdapter.loadArchiveYoung();
const archiveOld = await this._archiveDbAdapter.loadArchiveOld();
if (tagId in current.tag) {
const newTag = { ...current.tag };
@ -126,19 +125,15 @@ export class TimeTrackingService {
);
}
if (tagId in archiveYoung.timeTracking.tag) {
if (archiveYoung && tagId in archiveYoung.timeTracking.tag) {
delete archiveYoung.timeTracking.tag[tagId];
await this._pfapiService.m.archiveYoung.save(archiveYoung, {
isUpdateRevAndLastUpdate: true,
});
await this._archiveDbAdapter.saveArchiveYoung(archiveYoung);
this._archiveYoungUpdateTrigger$.next(undefined);
}
if (tagId in archiveOld.timeTracking.tag) {
if (archiveOld && tagId in archiveOld.timeTracking.tag) {
delete archiveOld.timeTracking.tag[tagId];
await this._pfapiService.m.archiveOld.save(archiveOld, {
isUpdateRevAndLastUpdate: true,
});
await this._archiveDbAdapter.saveArchiveOld(archiveOld);
this._archiveOldUpdateTrigger$.next(undefined);
}
}
@ -148,22 +143,18 @@ export class TimeTrackingService {
* Current state cleanup is handled atomically in tag-shared.reducer.ts.
*/
async cleanupArchiveDataForTag(tagId: string): Promise<void> {
const archiveYoung = await this._pfapiService.m.archiveYoung.load();
const archiveOld = await this._pfapiService.m.archiveOld.load();
const archiveYoung = await this._archiveDbAdapter.loadArchiveYoung();
const archiveOld = await this._archiveDbAdapter.loadArchiveOld();
if (tagId in archiveYoung.timeTracking.tag) {
if (archiveYoung && tagId in archiveYoung.timeTracking.tag) {
delete archiveYoung.timeTracking.tag[tagId];
await this._pfapiService.m.archiveYoung.save(archiveYoung, {
isUpdateRevAndLastUpdate: true,
});
await this._archiveDbAdapter.saveArchiveYoung(archiveYoung);
this._archiveYoungUpdateTrigger$.next(undefined);
}
if (tagId in archiveOld.timeTracking.tag) {
if (archiveOld && tagId in archiveOld.timeTracking.tag) {
delete archiveOld.timeTracking.tag[tagId];
await this._pfapiService.m.archiveOld.save(archiveOld, {
isUpdateRevAndLastUpdate: true,
});
await this._archiveDbAdapter.saveArchiveOld(archiveOld);
this._archiveOldUpdateTrigger$.next(undefined);
}
}

View file

@ -6,7 +6,7 @@ import {
UserProfile,
} from './user-profile.model';
import { Log } from '../../core/log';
import { CompleteBackup } from '../../pfapi/api';
import { CompleteBackup } from '../../sync/sync-exports';
/**
* Service for managing profile storage

View file

@ -1,11 +1,17 @@
import { inject, Injectable, Injector, signal } from '@angular/core';
import { DEFAULT_PROFILE_ID, ProfileMetadata, UserProfile } from './user-profile.model';
import { UserProfileStorageService } from './user-profile-storage.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { SyncProviderManager } from '../../sync/provider-manager.service';
import { BackupService } from '../../sync/backup.service';
import { SyncWrapperService } from '../../imex/sync/sync-wrapper.service';
import { OperationLogStoreService } from '../../op-log/store/operation-log-store.service';
import { Log } from '../../core/log';
import { nanoid } from 'nanoid';
import { SnackService } from '../../core/snack/snack.service';
import { GlobalConfigService } from '../config/global-config.service';
import { Store } from '@ngrx/store';
import { updateGlobalConfigSection } from '../config/store/global-config.actions';
import { DEFAULT_GLOBAL_CONFIG } from '../config/default-global-config.const';
/**
* Core service for user profile management
@ -16,10 +22,14 @@ import { GlobalConfigService } from '../config/global-config.service';
})
export class UserProfileService {
private readonly _storageService = inject(UserProfileStorageService);
private readonly _pfapiService = inject(PfapiService);
private readonly _providerManager = inject(SyncProviderManager);
private readonly _backupService = inject(BackupService);
private readonly _syncWrapperService = inject(SyncWrapperService);
private readonly _opLogStore = inject(OperationLogStoreService);
private readonly _snackService = inject(SnackService);
private readonly _injector = inject(Injector);
private readonly _globalConfigService = inject(GlobalConfigService);
private readonly _store = inject(Store);
readonly isInitialized = signal(false);
@ -268,12 +278,12 @@ export class UserProfileService {
);
try {
// Step 1: Trigger sync for current profile using pfapi directly
// Step 1: Trigger sync for current profile
Log.log('UserProfileService: Triggering sync before profile switch');
try {
const syncProvider = this._pfapiService.pf.getActiveSyncProvider();
const syncProvider = this._providerManager.getActiveProvider();
if (syncProvider) {
await this._pfapiService.pf.sync();
await this._syncWrapperService.sync();
Log.log('UserProfileService: Sync completed successfully');
} else {
Log.log('UserProfileService: No active sync provider, skipping sync');
@ -288,7 +298,7 @@ export class UserProfileService {
// Step 2: Save current profile data
Log.log('UserProfileService: Saving current profile data');
const currentData = await this._pfapiService.pf.loadCompleteBackup();
const currentData = await this._backupService.loadCompleteBackup(true);
Log.log('UserProfileService: Current profile data structure:', {
hasData: !!currentData,
hasDataData: !!currentData?.data,
@ -338,7 +348,7 @@ export class UserProfileService {
// Profile has existing data - import it
// importCompleteBackup will reload the window automatically
Log.log('UserProfileService: Importing target profile data (will reload app)');
await this._pfapiService.importCompleteBackup(
await this._backupService.importCompleteBackup(
targetData,
false, // isSkipLegacyWarnings
false, // isSkipReload - let it reload automatically
@ -349,20 +359,23 @@ export class UserProfileService {
Log.log(
'UserProfileService: Target profile has no data, clearing database for fresh start',
);
await this._pfapiService.pf.db.clearDatabase();
// Clear all operations to start fresh
await this._opLogStore.clearAllOperations();
// IMPORTANT: Enable user profiles in the new profile's config
// Otherwise the user won't see the profile button to switch back
// We directly write to the database to avoid race conditions with NgRx
// We dispatch to NgRx to update the config
Log.log('UserProfileService: Enabling user profiles in new profile config');
const defaultConfig = await this._pfapiService.pf.m.globalConfig.load();
await this._pfapiService.pf.m.globalConfig.save({
...defaultConfig,
appFeatures: {
...defaultConfig.appFeatures,
isEnableUserProfiles: true,
},
});
const defaultConfig = DEFAULT_GLOBAL_CONFIG;
this._store.dispatch(
updateGlobalConfigSection({
sectionKey: 'appFeatures',
sectionCfg: {
...defaultConfig.appFeatures,
isEnableUserProfiles: true,
} as any, // Type cast needed for section-specific config
}),
);
Log.log(
'UserProfileService: Database cleared and profiles enabled, reloading app',
@ -423,7 +436,7 @@ export class UserProfileService {
// Check if there's existing user data in the database
try {
const existingData = await this._pfapiService.pf.loadCompleteBackup();
const existingData = await this._backupService.loadCompleteBackup(true);
// Only save if there's actual data (check if any model has data)
const hasData = existingData && Object.keys(existingData.data || {}).length > 0;

View file

@ -10,7 +10,7 @@ import { TagService } from '../tag/tag.service';
import { GlobalTrackingIntervalService } from '../../core/global-tracking-interval/global-tracking-interval.service';
import { DateService } from '../../core/date/date.service';
import { TimeTrackingService } from '../time-tracking/time-tracking.service';
import { TaskArchiveService } from '../time-tracking/task-archive.service';
import { TaskArchiveService } from '../archive/task-archive.service';
import { TODAY_TAG } from '../tag/tag.const';
import { WorkContextType } from './work-context.model';

View file

@ -62,7 +62,7 @@ import { DateService } from 'src/app/core/date/date.service';
import { getTimeSpentForDay } from './get-time-spent-for-day.util';
import { TimeTrackingService } from '../time-tracking/time-tracking.service';
import { updateWorkContextData } from '../time-tracking/store/time-tracking.actions';
import { TaskArchiveService } from '../time-tracking/task-archive.service';
import { TaskArchiveService } from '../archive/task-archive.service';
import { INBOX_PROJECT } from '../project/project.const';
import { selectProjectById } from '../project/store/project.selectors';
import { getDbDateStr } from '../../util/get-db-date-str';

View file

@ -12,10 +12,9 @@ import { Worklog } from './worklog.model';
import { WorklogComponent } from './worklog.component';
import { WorklogService } from './worklog.service';
import { WorkContextService } from '../work-context/work-context.service';
import { TaskArchiveService } from '../time-tracking/task-archive.service';
import { TaskArchiveService } from '../archive/task-archive.service';
import { TaskService } from '../tasks/task.service';
import { Task } from '../tasks/task.model';
import { PfapiService } from '../../pfapi/pfapi.service';
import { selectAllProjectColorsAndTitles } from '../project/store/project.selectors';
import { mapArchiveToWorklog } from './util/map-archive-to-worklog';
@ -63,7 +62,6 @@ describe('WorklogComponent', () => {
provideMockActions(of()),
provideNoopAnimations(),
{ provide: ActivatedRoute, useValue: activatedRouteSpy },
{ provide: PfapiService, useValue: {} },
{ provide: TaskArchiveService, useValue: {} },
{ provide: TaskService, useValue: {} },
{ provide: WorkContextService, useValue: {} },

View file

@ -34,8 +34,7 @@ import { MsToClockStringPipe } from '../../ui/duration/ms-to-clock-string.pipe';
import { MsToStringPipe } from '../../ui/duration/ms-to-string.pipe';
import { NumberToMonthPipe } from '../../ui/pipes/number-to-month.pipe';
import { TranslatePipe } from '@ngx-translate/core';
import { PfapiService } from '../../pfapi/pfapi.service';
import { TaskArchiveService } from '../time-tracking/task-archive.service';
import { TaskArchiveService } from '../archive/task-archive.service';
import { Log } from '../../core/log';
@Component({
@ -67,7 +66,6 @@ import { Log } from '../../core/log';
export class WorklogComponent implements AfterViewInit, OnDestroy {
readonly worklogService = inject(WorklogService);
readonly workContextService = inject(WorkContextService);
private readonly _pfapiService = inject(PfapiService);
private readonly _taskService = inject(TaskService);
private readonly _matDialog = inject(MatDialog);
private readonly _router = inject(Router);

View file

@ -29,16 +29,14 @@ import { NavigationEnd, Router } from '@angular/router';
import { WorklogTask } from '../tasks/task.model';
import { mapArchiveToWorklogWeeks } from './util/map-archive-to-worklog-weeks';
import { DateAdapter } from '@angular/material/core';
import { PfapiService } from '../../pfapi/pfapi.service';
import { DataInitStateService } from '../../core/data-init/data-init-state.service';
import { TimeTrackingService } from '../time-tracking/time-tracking.service';
import { TaskArchiveService } from '../time-tracking/task-archive.service';
import { TaskArchiveService } from '../archive/task-archive.service';
import { getDbDateStr } from '../../util/get-db-date-str';
import { DateTimeFormatService } from 'src/app/core/date-time-format/date-time-format.service';
@Injectable({ providedIn: 'root' })
export class WorklogService {
private readonly _pfapiService = inject(PfapiService);
private readonly _workContextService = inject(WorkContextService);
private readonly _dataInitStateService = inject(DataInitStateService);
private readonly _taskService = inject(TaskService);

View file

@ -11,7 +11,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { FileImexComponent } from './file-imex.component';
import { SnackService } from '../../core/snack/snack.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { BackupService } from '../../sync/backup.service';
import { T } from '../../t.const';
import { TODAY_TAG } from '../../features/tag/tag.const';
import { ConfirmUrlImportDialogComponent } from '../dialog-confirm-url-import/dialog-confirm-url-import.component';
@ -23,7 +23,7 @@ describe('FileImexComponent', () => {
let fixture: ComponentFixture<FileImexComponent>;
let mockSnackService: jasmine.SpyObj<SnackService>;
let mockRouter: jasmine.SpyObj<Router>;
let mockPfapiService: jasmine.SpyObj<PfapiService>;
let mockBackupService: jasmine.SpyObj<BackupService>;
let mockActivatedRoute: any;
let mockMatDialog: jasmine.SpyObj<MatDialog>;
let httpTestingController: HttpTestingController;
@ -38,17 +38,11 @@ describe('FileImexComponent', () => {
const snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
const routerSpy = jasmine.createSpyObj('Router', ['navigate']);
const pfapiServiceSpy = jasmine.createSpyObj(
'PfapiService',
['importCompleteBackup'],
{
pf: {
loadCompleteBackup: jasmine
.createSpy()
.and.returnValue(Promise.resolve(mockAppData)),
},
},
);
const backupServiceSpy = jasmine.createSpyObj('BackupService', [
'importCompleteBackup',
'loadCompleteBackup',
]);
backupServiceSpy.loadCompleteBackup.and.returnValue(Promise.resolve(mockAppData));
const matDialogSpy = jasmine.createSpyObj('MatDialog', ['open']);
mockActivatedRoute = {
@ -56,7 +50,7 @@ describe('FileImexComponent', () => {
};
routerSpy.navigate.and.returnValue(Promise.resolve(true));
pfapiServiceSpy.importCompleteBackup.and.returnValue(Promise.resolve());
backupServiceSpy.importCompleteBackup.and.returnValue(Promise.resolve());
matDialogSpy.open.and.returnValue({
afterClosed: jasmine.createSpy().and.returnValue(of(false)),
} as any);
@ -68,7 +62,7 @@ describe('FileImexComponent', () => {
provideHttpClientTesting(),
{ provide: SnackService, useValue: snackServiceSpy },
{ provide: Router, useValue: routerSpy },
{ provide: PfapiService, useValue: pfapiServiceSpy },
{ provide: BackupService, useValue: backupServiceSpy },
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
{ provide: MatDialog, useValue: matDialogSpy },
],
@ -78,7 +72,7 @@ describe('FileImexComponent', () => {
component = fixture.componentInstance;
mockSnackService = TestBed.inject(SnackService) as jasmine.SpyObj<SnackService>;
mockRouter = TestBed.inject(Router) as jasmine.SpyObj<Router>;
mockPfapiService = TestBed.inject(PfapiService) as jasmine.SpyObj<PfapiService>;
mockBackupService = TestBed.inject(BackupService) as jasmine.SpyObj<BackupService>;
mockMatDialog = TestBed.inject(MatDialog) as jasmine.SpyObj<MatDialog>;
httpTestingController = TestBed.inject(HttpTestingController);
});
@ -323,7 +317,7 @@ describe('FileImexComponent', () => {
expect(mockRouter.navigate).toHaveBeenCalledWith([`tag/${TODAY_TAG.id}/tasks`]);
// importCompleteBackup is called with (data, isBackup=false, isForceConflict=true)
expect(mockPfapiService.importCompleteBackup).toHaveBeenCalledWith(
expect(mockBackupService.importCompleteBackup).toHaveBeenCalledWith(
jasmine.any(Object),
false,
true,
@ -339,7 +333,7 @@ describe('FileImexComponent', () => {
type: 'ERROR',
msg: T.FILE_IMEX.S_ERR_INVALID_DATA,
});
expect(mockPfapiService.importCompleteBackup).not.toHaveBeenCalled();
expect(mockBackupService.importCompleteBackup).not.toHaveBeenCalled();
});
it('should handle V1 data format', async () => {
@ -350,12 +344,12 @@ describe('FileImexComponent', () => {
expect(window.alert).toHaveBeenCalledWith(
'V1 Data. Migration not supported any more.',
);
expect(mockPfapiService.importCompleteBackup).not.toHaveBeenCalled();
expect(mockBackupService.importCompleteBackup).not.toHaveBeenCalled();
});
it('should handle import errors', async () => {
const validData = JSON.stringify(mockAppData);
mockPfapiService.importCompleteBackup.and.returnValue(
mockBackupService.importCompleteBackup.and.returnValue(
Promise.reject(new Error('Import failed')),
);
@ -381,7 +375,7 @@ describe('FileImexComponent', () => {
it('should load backup data', async () => {
await component.downloadBackup();
expect(mockPfapiService.pf.loadCompleteBackup).toHaveBeenCalled();
expect(mockBackupService.loadCompleteBackup).toHaveBeenCalled();
});
});
@ -389,7 +383,7 @@ describe('FileImexComponent', () => {
it('should load backup data for privacy export', async () => {
await component.privacyAppDataDownload();
expect(mockPfapiService.pf.loadCompleteBackup).toHaveBeenCalled();
expect(mockBackupService.loadCompleteBackup).toHaveBeenCalled();
});
});
});

View file

@ -19,8 +19,8 @@ import { MatIcon } from '@angular/material/icon';
import { MatButton } from '@angular/material/button';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { AppDataCompleteNew } from '../../pfapi/pfapi-config';
import { PfapiService } from 'src/app/pfapi/pfapi.service';
import { AppDataComplete } from '../../sync/model-config';
import { BackupService } from '../../sync/backup.service';
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { first } from 'rxjs/operators';
import {
@ -28,7 +28,7 @@ import {
DialogConfirmUrlImportData,
} from '../dialog-confirm-url-import/dialog-confirm-url-import.component';
import { Log } from '../../core/log';
import { DialogArchiveCompressionComponent } from '../../features/time-tracking/dialog-archive-compression/dialog-archive-compression.component';
import { DialogArchiveCompressionComponent } from '../../features/archive/dialog-archive-compression/dialog-archive-compression.component';
@Component({
selector: 'file-imex',
@ -40,7 +40,7 @@ import { DialogArchiveCompressionComponent } from '../../features/time-tracking/
export class FileImexComponent implements OnInit {
private _snackService = inject(SnackService);
private _router = inject(Router);
private _pfapiService = inject(PfapiService);
private _backupService = inject(BackupService);
private _activatedRoute = inject(ActivatedRoute);
private _matDialog = inject(MatDialog);
private _http = inject(HttpClient);
@ -155,7 +155,7 @@ export class FileImexComponent implements OnInit {
}
private async _processAndImportData(dataString: string): Promise<void> {
let data: AppDataCompleteNew | undefined;
let data: AppDataComplete | undefined;
let oldData: unknown; // For V1 legacy data format check
try {
@ -183,8 +183,8 @@ export class FileImexComponent implements OnInit {
try {
// Import first, then navigate (no page reload, state updates inline)
// isForceConflict=true resets vector clock to prevent accumulation of old client IDs
await this._pfapiService.importCompleteBackup(
data as AppDataCompleteNew,
await this._backupService.importCompleteBackup(
data as AppDataComplete,
false,
true,
);
@ -199,7 +199,7 @@ export class FileImexComponent implements OnInit {
}
async downloadBackup(): Promise<void> {
const data = await this._pfapiService.pf.loadCompleteBackup();
const data = await this._backupService.loadCompleteBackup(true);
const result = await download('super-productivity-backup.json', JSON.stringify(data));
if ((IS_ANDROID_WEB_VIEW && !result.wasCanceled) || result.isSnap) {
this._snackService.open({
@ -213,7 +213,7 @@ export class FileImexComponent implements OnInit {
}
async privacyAppDataDownload(): Promise<void> {
const data = await this._pfapiService.pf.loadCompleteBackup();
const data = await this._backupService.loadCompleteBackup(true);
const result = await download('super-productivity-backup.json', privacyExport(data));
if ((IS_ANDROID_WEB_VIEW && !result.wasCanceled) || result.isSnap) {
this._snackService.open({

View file

@ -7,10 +7,11 @@ import { LocalBackupMeta } from './local-backup.model';
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { IS_ELECTRON } from '../../app.constants';
import { androidInterface } from '../../features/android/android-interface';
import { PfapiService } from '../../pfapi/pfapi.service';
import { StateSnapshotService } from '../../sync/state-snapshot.service';
import { BackupService } from '../../sync/backup.service';
import { T } from '../../t.const';
import { TranslateService } from '@ngx-translate/core';
import { AppDataCompleteNew } from '../../pfapi/pfapi-config';
import { AppDataComplete } from '../../sync/model-config';
import { SnackService } from '../../core/snack/snack.service';
import { Log } from '../../core/log';
@ -24,7 +25,8 @@ const ANDROID_DB_KEY = 'backup';
})
export class LocalBackupService {
private _configService = inject(GlobalConfigService);
private _pfapiService = inject(PfapiService);
private _stateSnapshotService = inject(StateSnapshotService);
private _backupService = inject(BackupService);
private _snackService = inject(SnackService);
private _translateService = inject(TranslateService);
@ -93,7 +95,8 @@ export class LocalBackupService {
}
private async _backup(): Promise<void> {
const data = await this._pfapiService.pf.getAllSyncModelData();
const data =
this._stateSnapshotService.getAllSyncModelDataFromStore() as AppDataComplete;
if (IS_ELECTRON) {
window.ea.backupAppData(data);
}
@ -105,8 +108,8 @@ export class LocalBackupService {
private async _importBackup(backupData: string): Promise<void> {
try {
// isForceConflict=true resets vector clock to prevent accumulation of old client IDs
await this._pfapiService.importCompleteBackup(
JSON.parse(backupData) as AppDataCompleteNew,
await this._backupService.importCompleteBackup(
JSON.parse(backupData) as AppDataComplete,
false,
true,
);

View file

@ -13,7 +13,7 @@ import { IS_ELECTRON } from '../../../app.constants';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { MatIcon } from '@angular/material/icon';
import { MatButton } from '@angular/material/button';
import { PfapiService } from '../../../pfapi/pfapi.service';
import { BackupService } from '../../../sync/backup.service';
import { T } from '../../../t.const';
import { Log } from '../../../core/log';
@ -38,7 +38,7 @@ export interface DialogIncompleteSyncData {
export class DialogIncoherentTimestampsErrorComponent {
private _matDialogRef =
inject<MatDialogRef<DialogIncoherentTimestampsErrorComponent>>(MatDialogRef);
private _pfapiService = inject(PfapiService);
private _backupService = inject(BackupService);
data = inject<DialogIncompleteSyncData>(MAT_DIALOG_DATA);
@ -51,7 +51,7 @@ export class DialogIncoherentTimestampsErrorComponent {
}
async downloadBackup(): Promise<void> {
const data = await this._pfapiService.pf.loadCompleteBackup();
const data = await this._backupService.loadCompleteBackup(true);
try {
await download('super-productivity-backup.json', JSON.stringify(data));
} catch (e) {

View file

@ -13,7 +13,7 @@ import { IS_ELECTRON } from '../../../app.constants';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { MatIcon } from '@angular/material/icon';
import { MatButton } from '@angular/material/button';
import { PfapiService } from '../../../pfapi/pfapi.service';
import { BackupService } from '../../../sync/backup.service';
import { T } from '../../../t.const';
import { Log } from '../../../core/log';
@ -39,7 +39,7 @@ export interface DialogIncompleteSyncData {
export class DialogIncompleteSyncOldComponent {
private _matDialogRef =
inject<MatDialogRef<DialogIncompleteSyncOldComponent>>(MatDialogRef);
private _pfapiService = inject(PfapiService);
private _backupService = inject(BackupService);
data? = inject<DialogIncompleteSyncData>(MAT_DIALOG_DATA);
T: typeof T = T;
@ -52,7 +52,7 @@ export class DialogIncompleteSyncOldComponent {
}
async downloadBackup(): Promise<void> {
const data = await this._pfapiService.pf.loadCompleteBackup();
const data = await this._backupService.loadCompleteBackup(true);
try {
await download('super-productivity-backup.json', JSON.stringify(data));
} catch (e) {

View file

@ -13,7 +13,7 @@ import { IS_ELECTRON } from '../../../app.constants';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { MatIcon } from '@angular/material/icon';
import { MatButton } from '@angular/material/button';
import { PfapiService } from '../../../pfapi/pfapi.service';
import { BackupService } from '../../../sync/backup.service';
import { T } from '../../../t.const';
import { Log } from '../../../core/log';
@ -38,7 +38,7 @@ export interface DialogIncompleteSyncData {
export class DialogIncompleteSyncComponent {
private _matDialogRef =
inject<MatDialogRef<DialogIncompleteSyncComponent>>(MatDialogRef);
private _pfapiService = inject(PfapiService);
private _backupService = inject(BackupService);
data = inject<DialogIncompleteSyncData>(MAT_DIALOG_DATA);
@ -51,7 +51,7 @@ export class DialogIncompleteSyncComponent {
}
async downloadBackup(): Promise<void> {
const data = await this._pfapiService.pf.loadCompleteBackup();
const data = await this._backupService.loadCompleteBackup(true);
try {
await download('super-productivity-backup.json', JSON.stringify(data));
} catch (e) {

View file

@ -3,7 +3,7 @@ import { DialogRestorePointComponent } from './dialog-restore-point.component';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { SuperSyncRestoreService } from '../super-sync-restore.service';
import { TranslateModule } from '@ngx-translate/core';
import { RestorePoint } from '../../../pfapi/api/sync/sync-provider.interface';
import { RestorePoint } from '../../../sync/providers/provider.interface';
import { T } from '../../../t.const';
import { of } from 'rxjs';

View file

@ -18,7 +18,7 @@ import { DatePipe } from '@angular/common';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { SuperSyncRestoreService } from '../super-sync-restore.service';
import { RestorePoint } from '../../../pfapi/api/sync/sync-provider.interface';
import { RestorePoint } from '../../../sync/providers/provider.interface';
import { T } from '../../../t.const';
import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component';
import { firstValueFrom } from 'rxjs';

View file

@ -9,7 +9,7 @@ import {
} from '@angular/material/dialog';
import { T } from 'src/app/t.const';
import { DialogConflictResolutionResult } from '../sync.model';
import { ConflictData, VectorClock } from '../../../pfapi/api';
import { ConflictData, VectorClock } from '../../../sync/sync-exports';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';

View file

@ -21,7 +21,7 @@ import { SyncWrapperService } from '../sync-wrapper.service';
import { EncryptionPasswordDialogOpenerService } from '../encryption-password-dialog-opener.service';
import { Subscription } from 'rxjs';
import { first } from 'rxjs/operators';
import { SyncProviderId } from '../../../pfapi/api';
import { SyncProviderId } from '../../../sync/sync-exports';
import { SyncLog } from '../../../core/log';
@Component({

View file

@ -6,13 +6,13 @@ import { filter, tap, withLatestFrom } from 'rxjs/operators';
import { SyncConfig } from '../../../../features/config/global-config.model';
import { updateGlobalConfigSection } from '../../../../features/config/store/global-config.actions';
import { environment } from '../../../../../environments/environment';
import { PfapiService } from '../../../../pfapi/pfapi.service';
import { DropboxPrivateCfg, SyncProviderId } from '../../../../pfapi/api';
import { SyncProviderManager } from '../../../../sync/provider-manager.service';
import { DropboxPrivateCfg, SyncProviderId } from '../../../../sync/sync-exports';
@Injectable()
export class DropboxEffects {
private _actions$ = inject(LOCAL_ACTIONS);
private _pfapiService = inject(PfapiService);
private _providerManager = inject(SyncProviderManager);
askToDeleteTokensOnDisable$: Observable<unknown> = createEffect(
() =>
@ -22,7 +22,7 @@ export class DropboxEffects {
({ sectionKey, sectionCfg }): boolean =>
sectionKey === 'sync' && (sectionCfg as SyncConfig).isEnabled === false,
),
withLatestFrom(this._pfapiService.currentProviderPrivateCfg$),
withLatestFrom(this._providerManager.currentProviderPrivateCfg$),
tap(async ([, provider]) => {
if (
provider?.providerId === SyncProviderId.Dropbox &&
@ -33,14 +33,11 @@ export class DropboxEffects {
}
alert('Delete tokens');
const existingConfig = provider.privateCfg as DropboxPrivateCfg;
await this._pfapiService.pf.setPrivateCfgForSyncProvider(
SyncProviderId.Dropbox,
{
...existingConfig,
accessToken: '',
refreshToken: '',
},
);
await this._providerManager.setProviderConfig(SyncProviderId.Dropbox, {
...existingConfig,
accessToken: '',
refreshToken: '',
});
}
}),
),

View file

@ -1,16 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { EncryptionPasswordChangeService } from './encryption-password-change.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { PfapiStoreDelegateService } from '../../pfapi/pfapi-store-delegate.service';
import { SyncProviderManager } from '../../sync/provider-manager.service';
import { StateSnapshotService } from '../../sync/state-snapshot.service';
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
import { VectorClockService } from '../../op-log/sync/vector-clock.service';
import { CLIENT_ID_PROVIDER } from '../../op-log/util/client-id.provider';
import { SyncProviderId } from '../../pfapi/api/pfapi.const';
import { SyncProviderId } from '../../sync/providers/provider.const';
describe('EncryptionPasswordChangeService', () => {
let service: EncryptionPasswordChangeService;
let mockPfapiService: jasmine.SpyObj<any>;
let mockStoreDelegateService: jasmine.SpyObj<PfapiStoreDelegateService>;
let mockProviderManager: jasmine.SpyObj<any>;
let mockStateSnapshotService: jasmine.SpyObj<StateSnapshotService>;
let mockEncryptionService: jasmine.SpyObj<OperationEncryptionService>;
let mockVectorClockService: jasmine.SpyObj<VectorClockService>;
let mockClientIdProvider: jasmine.SpyObj<any>;
@ -54,21 +54,17 @@ describe('EncryptionPasswordChangeService', () => {
mockSyncProvider.setPrivateCfg.and.returnValue(Promise.resolve());
mockSyncProvider.setLastServerSeq.and.returnValue(Promise.resolve());
// Create mock PfapiService
mockPfapiService = {
pf: {
getActiveSyncProvider: jasmine
.createSpy('getActiveSyncProvider')
.and.returnValue(mockSyncProvider),
},
// Create mock SyncProviderManager
mockProviderManager = {
getActiveProvider: jasmine
.createSpy('getActiveProvider')
.and.returnValue(mockSyncProvider),
};
mockStoreDelegateService = jasmine.createSpyObj('PfapiStoreDelegateService', [
'getAllSyncModelDataFromStore',
mockStateSnapshotService = jasmine.createSpyObj('StateSnapshotService', [
'getStateSnapshot',
]);
mockStoreDelegateService.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(TEST_CURRENT_STATE),
);
mockStateSnapshotService.getStateSnapshot.and.returnValue(TEST_CURRENT_STATE);
mockEncryptionService = jasmine.createSpyObj('OperationEncryptionService', [
'encryptPayload',
@ -93,8 +89,8 @@ describe('EncryptionPasswordChangeService', () => {
TestBed.configureTestingModule({
providers: [
EncryptionPasswordChangeService,
{ provide: PfapiService, useValue: mockPfapiService },
{ provide: PfapiStoreDelegateService, useValue: mockStoreDelegateService },
{ provide: SyncProviderManager, useValue: mockProviderManager },
{ provide: StateSnapshotService, useValue: mockStateSnapshotService },
{ provide: OperationEncryptionService, useValue: mockEncryptionService },
{ provide: VectorClockService, useValue: mockVectorClockService },
{ provide: CLIENT_ID_PROVIDER, useValue: mockClientIdProvider },
@ -108,7 +104,7 @@ describe('EncryptionPasswordChangeService', () => {
await service.changePassword(TEST_PASSWORD);
// Verify correct sequence of operations
expect(mockStoreDelegateService.getAllSyncModelDataFromStore).toHaveBeenCalled();
expect(mockStateSnapshotService.getStateSnapshot).toHaveBeenCalled();
expect(mockVectorClockService.getCurrentVectorClock).toHaveBeenCalled();
expect(mockClientIdProvider.loadClientId).toHaveBeenCalled();
expect(mockSyncProvider.deleteAllData).toHaveBeenCalled();
@ -142,7 +138,7 @@ describe('EncryptionPasswordChangeService', () => {
});
it('should throw error if no sync provider is active', async () => {
mockPfapiService.pf.getActiveSyncProvider.and.returnValue(null);
mockProviderManager.getActiveProvider.and.returnValue(null);
await expectAsync(service.changePassword(TEST_PASSWORD)).toBeRejectedWithError(
'Password change is only supported for SuperSync',

View file

@ -1,6 +1,6 @@
import { inject, Injectable } from '@angular/core';
import { PfapiService } from '../../pfapi/pfapi.service';
import { PfapiStoreDelegateService } from '../../pfapi/pfapi-store-delegate.service';
import { SyncProviderManager } from '../../sync/provider-manager.service';
import { StateSnapshotService } from '../../sync/state-snapshot.service';
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
import { VectorClockService } from '../../op-log/sync/vector-clock.service';
import {
@ -8,8 +8,8 @@ import {
ClientIdProvider,
} from '../../op-log/util/client-id.provider';
import { isOperationSyncCapable } from '../../op-log/sync/operation-sync.util';
import { SyncProviderId } from '../../pfapi/api/pfapi.const';
import { SuperSyncPrivateCfg } from '../../pfapi/api/sync/providers/super-sync/super-sync.model';
import { SyncProviderId } from '../../sync/providers/provider.const';
import { SuperSyncPrivateCfg } from '../../sync/providers/super-sync/super-sync.model';
import { CURRENT_SCHEMA_VERSION } from '../../op-log/store/schema-migration.service';
import { SyncLog } from '../../core/log';
@ -25,8 +25,8 @@ import { SyncLog } from '../../core/log';
providedIn: 'root',
})
export class EncryptionPasswordChangeService {
private _pfapiService = inject(PfapiService);
private _storeDelegateService = inject(PfapiStoreDelegateService);
private _providerManager = inject(SyncProviderManager);
private _stateSnapshotService = inject(StateSnapshotService);
private _encryptionService = inject(OperationEncryptionService);
private _vectorClockService = inject(VectorClockService);
private _clientIdProvider: ClientIdProvider = inject(CLIENT_ID_PROVIDER);
@ -46,7 +46,7 @@ export class EncryptionPasswordChangeService {
SyncLog.normal('EncryptionPasswordChangeService: Starting password change...');
// Get the sync provider
const syncProvider = this._pfapiService.pf.getActiveSyncProvider();
const syncProvider = this._providerManager.getActiveProvider();
if (!syncProvider || syncProvider.id !== SyncProviderId.SuperSync) {
throw new Error('Password change is only supported for SuperSync');
}
@ -62,7 +62,7 @@ export class EncryptionPasswordChangeService {
// Get current state
SyncLog.normal('EncryptionPasswordChangeService: Getting current state...');
const currentState = await this._storeDelegateService.getAllSyncModelDataFromStore();
const currentState = this._stateSnapshotService.getStateSnapshot();
const vectorClock = await this._vectorClockService.getCurrentVectorClock();
const clientId = await this._clientIdProvider.loadClientId();
if (!clientId) {

View file

@ -1,14 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { SuperSyncRestoreService } from './super-sync-restore.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { SyncProviderManager } from '../../sync/provider-manager.service';
import { BackupService } from '../../sync/backup.service';
import { SnackService } from '../../core/snack/snack.service';
import { SyncProviderId } from '../../pfapi/api/pfapi.const';
import { RestorePoint } from '../../pfapi/api/sync/sync-provider.interface';
import { SyncProviderId } from '../../sync/providers/provider.const';
import { RestorePoint } from '../../sync/providers/provider.interface';
import { T } from '../../t.const';
describe('SuperSyncRestoreService', () => {
let service: SuperSyncRestoreService;
let mockPfapiService: jasmine.SpyObj<PfapiService>;
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
let mockBackupService: jasmine.SpyObj<BackupService>;
let mockSnackService: jasmine.SpyObj<SnackService>;
let mockProvider: any;
@ -20,22 +22,20 @@ describe('SuperSyncRestoreService', () => {
getStateAtSeq: jasmine.createSpy('getStateAtSeq'),
};
const mockPf = {
getActiveSyncProvider: jasmine
.createSpy('getActiveSyncProvider')
.and.returnValue(mockProvider),
};
mockProviderManager = jasmine.createSpyObj('SyncProviderManager', [
'getActiveProvider',
]);
mockProviderManager.getActiveProvider.and.returnValue(mockProvider);
mockPfapiService = jasmine.createSpyObj('PfapiService', ['importCompleteBackup'], {
pf: mockPf,
});
mockBackupService = jasmine.createSpyObj('BackupService', ['importCompleteBackup']);
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
TestBed.configureTestingModule({
providers: [
SuperSyncRestoreService,
{ provide: PfapiService, useValue: mockPfapiService },
{ provide: SyncProviderManager, useValue: mockProviderManager },
{ provide: BackupService, useValue: mockBackupService },
{ provide: SnackService, useValue: mockSnackService },
],
});
@ -62,7 +62,7 @@ describe('SuperSyncRestoreService', () => {
});
it('should return false when SuperSync is not the active provider', async () => {
mockPfapiService.pf.getActiveSyncProvider = jasmine.createSpy().and.returnValue({
mockProviderManager.getActiveProvider = jasmine.createSpy().and.returnValue({
id: SyncProviderId.WebDAV,
});
@ -72,9 +72,7 @@ describe('SuperSyncRestoreService', () => {
});
it('should return false when no provider is active', async () => {
mockPfapiService.pf.getActiveSyncProvider = jasmine
.createSpy()
.and.returnValue(null);
mockProviderManager.getActiveProvider = jasmine.createSpy().and.returnValue(null);
const result = await service.isAvailable();
@ -116,9 +114,7 @@ describe('SuperSyncRestoreService', () => {
});
it('should throw error when SuperSync is not active', async () => {
mockPfapiService.pf.getActiveSyncProvider = jasmine
.createSpy()
.and.returnValue(null);
mockProviderManager.getActiveProvider = jasmine.createSpy().and.returnValue(null);
await expectAsync(service.getRestorePoints()).toBeRejectedWithError(
'Super Sync is not the active sync provider',
@ -126,7 +122,7 @@ describe('SuperSyncRestoreService', () => {
});
it('should throw error when different provider is active', async () => {
mockPfapiService.pf.getActiveSyncProvider = jasmine.createSpy().and.returnValue({
mockProviderManager.getActiveProvider = jasmine.createSpy().and.returnValue({
id: SyncProviderId.Dropbox,
});
@ -150,14 +146,14 @@ describe('SuperSyncRestoreService', () => {
generatedAt: Date.now(),
}),
);
mockPfapiService.importCompleteBackup.and.returnValue(Promise.resolve());
mockBackupService.importCompleteBackup.and.returnValue(Promise.resolve());
});
it('should fetch state and import backup successfully', async () => {
await service.restoreToPoint(100);
expect(mockProvider.getStateAtSeq).toHaveBeenCalledWith(100);
expect(mockPfapiService.importCompleteBackup).toHaveBeenCalledWith(
expect(mockBackupService.importCompleteBackup).toHaveBeenCalledWith(
mockState as any,
true, // isSkipLegacyWarnings
true, // isSkipReload
@ -183,7 +179,7 @@ describe('SuperSyncRestoreService', () => {
it('should show error when import fails', async () => {
const error = new Error('Import failed');
mockPfapiService.importCompleteBackup.and.returnValue(Promise.reject(error));
mockBackupService.importCompleteBackup.and.returnValue(Promise.reject(error));
await expectAsync(service.restoreToPoint(100)).toBeRejectedWith(error);
@ -194,9 +190,7 @@ describe('SuperSyncRestoreService', () => {
});
it('should throw when SuperSync is not active', async () => {
mockPfapiService.pf.getActiveSyncProvider = jasmine
.createSpy()
.and.returnValue(null);
mockProviderManager.getActiveProvider = jasmine.createSpy().and.returnValue(null);
await expectAsync(service.restoreToPoint(100)).toBeRejectedWithError(
'Super Sync is not the active sync provider',
@ -204,20 +198,18 @@ describe('SuperSyncRestoreService', () => {
});
});
describe('lazy PfapiService injection', () => {
it('should use Injector.get() to lazily load PfapiService', () => {
// The service uses Injector.get() instead of direct inject()
// This test verifies the service can be instantiated without errors
describe('service instantiation', () => {
it('should instantiate without errors', () => {
expect(service).toBeTruthy();
});
it('should cache PfapiService after first access', async () => {
it('should use injected services for operations', async () => {
// Access the service multiple times
await service.isAvailable();
await service.isAvailable();
// The same mock is used, which means caching works
expect(mockPfapiService.pf.getActiveSyncProvider).toHaveBeenCalledTimes(2);
// The same mocks are used for both calls
expect(mockProviderManager.getActiveProvider).toHaveBeenCalledTimes(2);
});
});
});

View file

@ -1,14 +1,12 @@
import { inject, Injectable, Injector } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { SnackService } from '../../core/snack/snack.service';
import { SyncProviderId } from '../../pfapi/api/pfapi.const';
import { SuperSyncProvider } from '../../pfapi/api/sync/providers/super-sync/super-sync';
import {
RestoreCapable,
RestorePoint,
} from '../../pfapi/api/sync/sync-provider.interface';
import { AppDataCompleteNew } from '../../pfapi/pfapi-config';
import { SyncProviderId } from '../../sync/providers/provider.const';
import { SuperSyncProvider } from '../../sync/providers/super-sync/super-sync';
import { RestoreCapable, RestorePoint } from '../../sync/providers/provider.interface';
import { AppDataComplete } from '../../sync/model-config';
import { T } from '../../t.const';
import { PfapiService } from '../../pfapi/pfapi.service';
import { SyncProviderManager } from '../../sync/provider-manager.service';
import { BackupService } from '../../sync/backup.service';
/**
* Service for restoring state from Super Sync server history.
@ -16,18 +14,9 @@ import { PfapiService } from '../../pfapi/pfapi.service';
*/
@Injectable({ providedIn: 'root' })
export class SuperSyncRestoreService {
private _injector = inject(Injector);
private _snackService = inject(SnackService);
// Lazy-loaded PfapiService to avoid potential circular dependency
// We use Injector.get() instead of direct inject() to defer resolution
private _pfapiService: PfapiService | null = null;
private _getPfapiService(): PfapiService {
if (!this._pfapiService) {
this._pfapiService = this._injector.get(PfapiService);
}
return this._pfapiService;
}
private _providerManager = inject(SyncProviderManager);
private _backupService = inject(BackupService);
/**
* Check if Super Sync restore is available.
@ -63,8 +52,8 @@ export class SuperSyncRestoreService {
// 2. Import with isForceConflict=true to generate fresh vector clock
// This ensures the restored state syncs cleanly to all devices
await this._getPfapiService().importCompleteBackup(
snapshot.state as AppDataCompleteNew,
await this._backupService.importCompleteBackup(
snapshot.state as AppDataComplete,
true, // isSkipLegacyWarnings
true, // isSkipReload - no page reload needed
true, // isForceConflict - generates fresh vector clock
@ -88,7 +77,7 @@ export class SuperSyncRestoreService {
* Get the Super Sync provider if it's the active provider.
*/
private _getProvider(): SuperSyncProvider | null {
const provider = this._getPfapiService().pf.getActiveSyncProvider();
const provider = this._providerManager.getActiveProvider();
if (!provider || provider.id !== SyncProviderId.SuperSync) {
return null;
}

View file

@ -1,17 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { SyncConfigService } from './sync-config.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { SyncProviderManager } from '../../sync/provider-manager.service';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { BehaviorSubject } from 'rxjs';
import { SyncConfig } from '../../features/config/global-config.model';
import { LegacySyncProvider } from './legacy-sync-provider.model';
import { SyncProviderId } from '../../pfapi/api';
import { SyncProviderId } from '../../sync/sync-exports';
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
import { first } from 'rxjs/operators';
describe('SyncConfigService', () => {
let service: SyncConfigService;
let pfapiService: jasmine.SpyObj<PfapiService>;
let providerManager: jasmine.SpyObj<SyncProviderManager>;
let mockSyncConfig$: BehaviorSubject<SyncConfig>;
let mockCurrentProviderPrivateCfg$: BehaviorSubject<any>;
@ -34,16 +34,13 @@ describe('SyncConfigService', () => {
mockCurrentProviderPrivateCfg$ = new BehaviorSubject(null);
const mockPf = {
getSyncProviderById: jasmine.createSpy('getSyncProviderById'),
getActiveSyncProvider: jasmine.createSpy('getActiveSyncProvider'),
setPrivateCfgForSyncProvider: jasmine.createSpy('setPrivateCfgForSyncProvider'),
};
const pfapiServiceSpy = jasmine.createSpyObj('PfapiService', [], {
currentProviderPrivateCfg$: mockCurrentProviderPrivateCfg$,
pf: mockPf,
});
const providerManagerSpy = jasmine.createSpyObj(
'SyncProviderManager',
['getProviderById', 'getActiveProvider', 'setProviderConfig', 'getProviderConfig'],
{
currentProviderPrivateCfg$: mockCurrentProviderPrivateCfg$,
},
);
const globalConfigServiceSpy = jasmine.createSpyObj(
'GlobalConfigService',
@ -56,13 +53,15 @@ describe('SyncConfigService', () => {
TestBed.configureTestingModule({
providers: [
SyncConfigService,
{ provide: PfapiService, useValue: pfapiServiceSpy },
{ provide: SyncProviderManager, useValue: providerManagerSpy },
{ provide: GlobalConfigService, useValue: globalConfigServiceSpy },
],
});
service = TestBed.inject(SyncConfigService);
pfapiService = TestBed.inject(PfapiService) as jasmine.SpyObj<PfapiService>;
providerManager = TestBed.inject(
SyncProviderManager,
) as jasmine.SpyObj<SyncProviderManager>;
});
describe('updateSettingsFromForm', () => {
@ -112,7 +111,7 @@ describe('SyncConfigService', () => {
),
},
};
(pfapiService.pf.getSyncProviderById as jasmine.Spy).and.returnValue(
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
@ -130,7 +129,7 @@ describe('SyncConfigService', () => {
await service.updateSettingsFromForm(settings);
expect(pfapiService.pf.setPrivateCfgForSyncProvider).toHaveBeenCalledWith(
expect(providerManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.WebDAV,
{
baseUrl: 'https://example.com',
@ -144,7 +143,7 @@ describe('SyncConfigService', () => {
it('should apply default values for LocalFile provider fields when no existing config', async () => {
// Mock no existing provider
(pfapiService.pf.getSyncProviderById as jasmine.Spy).and.returnValue(
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(null),
);
@ -161,7 +160,7 @@ describe('SyncConfigService', () => {
await service.updateSettingsFromForm(settings);
expect(pfapiService.pf.setPrivateCfgForSyncProvider).toHaveBeenCalledWith(
expect(providerManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.LocalFile,
{
syncFolderPath: '',
@ -184,7 +183,7 @@ describe('SyncConfigService', () => {
),
},
};
(pfapiService.pf.getSyncProviderById as jasmine.Spy).and.returnValue(
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
@ -198,7 +197,7 @@ describe('SyncConfigService', () => {
await service.updateSettingsFromForm(settings);
expect(pfapiService.pf.setPrivateCfgForSyncProvider).toHaveBeenCalledWith(
expect(providerManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.Dropbox,
{
accessToken: 'existing-access-token', // Preserved OAuth tokens
@ -225,7 +224,7 @@ describe('SyncConfigService', () => {
),
},
};
(pfapiService.pf.getSyncProviderById as jasmine.Spy).and.returnValue(
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
@ -241,7 +240,7 @@ describe('SyncConfigService', () => {
await service.updateSettingsFromForm(settings);
// Verify the token is preserved
expect(pfapiService.pf.setPrivateCfgForSyncProvider).toHaveBeenCalledWith(
expect(providerManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.Dropbox,
jasmine.objectContaining({
accessToken: existingToken, // Must be preserved!
@ -252,7 +251,7 @@ describe('SyncConfigService', () => {
it('should prevent duplicate saves when settings are unchanged', async () => {
// Mock provider for the test
(pfapiService.pf.getSyncProviderById as jasmine.Spy).and.returnValue(
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve({
id: SyncProviderId.WebDAV,
privateCfg: {
@ -276,15 +275,15 @@ describe('SyncConfigService', () => {
// First call
await service.updateSettingsFromForm(settings);
expect(pfapiService.pf.setPrivateCfgForSyncProvider).toHaveBeenCalledTimes(1);
expect(providerManager.setProviderConfig).toHaveBeenCalledTimes(1);
// Second call with same settings - should be skipped
await service.updateSettingsFromForm(settings);
expect(pfapiService.pf.setPrivateCfgForSyncProvider).toHaveBeenCalledTimes(1);
expect(providerManager.setProviderConfig).toHaveBeenCalledTimes(1);
// Third call with isForce=true - should proceed
await service.updateSettingsFromForm(settings, true);
expect(pfapiService.pf.setPrivateCfgForSyncProvider).toHaveBeenCalledTimes(2);
expect(providerManager.setProviderConfig).toHaveBeenCalledTimes(2);
});
it('should not save private config when no provider is selected', async () => {
@ -297,12 +296,12 @@ describe('SyncConfigService', () => {
await service.updateSettingsFromForm(settings);
expect(pfapiService.pf.setPrivateCfgForSyncProvider).not.toHaveBeenCalled();
expect(providerManager.setProviderConfig).not.toHaveBeenCalled();
});
it('should handle provider with no existing config', async () => {
// Mock no existing provider (e.g., initial setup)
(pfapiService.pf.getSyncProviderById as jasmine.Spy).and.returnValue(
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(null),
);
@ -322,7 +321,7 @@ describe('SyncConfigService', () => {
await service.updateSettingsFromForm(settings);
expect(pfapiService.pf.setPrivateCfgForSyncProvider).toHaveBeenCalledWith(
expect(providerManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.WebDAV,
{
baseUrl: 'https://example.com',
@ -353,8 +352,8 @@ describe('SyncConfigService', () => {
};
// Mock: No provider exists initially
(pfapiService.pf.getActiveSyncProvider as jasmine.Spy).and.returnValue(null);
(pfapiService.pf.getSyncProviderById as jasmine.Spy).and.returnValue(
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(null);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(null),
);
@ -363,7 +362,7 @@ describe('SyncConfigService', () => {
// The provider should be created/initialized
// and the encryption key should be saved to the provider's private config
expect(pfapiService.pf.setPrivateCfgForSyncProvider).toHaveBeenCalledWith(
expect(providerManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.LocalFile,
jasmine.objectContaining({
syncFolderPath: 'C:\\Users\\test\\sync',
@ -409,8 +408,8 @@ describe('SyncConfigService', () => {
};
// No provider exists yet
(pfapiService.pf.getActiveSyncProvider as jasmine.Spy).and.returnValue(null);
(pfapiService.pf.getSyncProviderById as jasmine.Spy).and.returnValue(
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(null);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(null),
);
@ -483,14 +482,14 @@ describe('SyncConfigService', () => {
load: jasmine.createSpy('load').and.returnValue(Promise.resolve({})),
},
};
(pfapiService.pf.getSyncProviderById as jasmine.Spy).and.returnValue(
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);
await service.updateSettingsFromForm(webDavSettings);
// Verify WebDAV config is saved correctly with encryption key
expect(pfapiService.pf.setPrivateCfgForSyncProvider).toHaveBeenCalledWith(
expect(providerManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.WebDAV,
jasmine.objectContaining({
baseUrl: 'https://example.com/webdav',
@ -518,8 +517,8 @@ describe('SyncConfigService', () => {
};
// Mock that there's no active provider yet (initial setup)
(pfapiService.pf.getActiveSyncProvider as jasmine.Spy).and.returnValue(null);
(pfapiService.pf.getSyncProviderById as jasmine.Spy).and.returnValue(
(providerManager.getActiveProvider as jasmine.Spy).and.returnValue(null);
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(null),
);
@ -527,7 +526,7 @@ describe('SyncConfigService', () => {
await service.updateSettingsFromForm(newSettings);
// Verify that setPrivateCfgForSyncProvider was called with encryption key
expect(pfapiService.pf.setPrivateCfgForSyncProvider).toHaveBeenCalledWith(
expect(providerManager.setProviderConfig).toHaveBeenCalledWith(
SyncProviderId.LocalFile,
jasmine.objectContaining({
syncFolderPath: 'C:\\Users\\test\\sync',
@ -549,7 +548,7 @@ describe('SyncConfigService', () => {
};
// Update mocks to simulate provider is now available
(pfapiService.pf.getSyncProviderById as jasmine.Spy).and.returnValue(
(providerManager.getProviderById as jasmine.Spy).and.returnValue(
Promise.resolve(mockProvider),
);

View file

@ -1,10 +1,10 @@
import { inject, Injectable } from '@angular/core';
import { PfapiService } from '../../pfapi/pfapi.service';
import { SyncProviderManager } from '../../sync/provider-manager.service';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { combineLatest, from, Observable, of } from 'rxjs';
import { SyncConfig } from '../../features/config/global-config.model';
import { switchMap, tap } from 'rxjs/operators';
import { PrivateCfgByProviderId, SyncProviderId } from '../../pfapi/api';
import { PrivateCfgByProviderId, SyncProviderId } from '../../sync/sync-exports';
import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const';
import { SyncLog } from '../../core/log';
@ -81,14 +81,14 @@ const PROVIDER_FIELD_DEFAULTS: Record<
providedIn: 'root',
})
export class SyncConfigService {
private _pfapiService = inject(PfapiService);
private _providerManager = inject(SyncProviderManager);
private _globalConfigService = inject(GlobalConfigService);
private _lastSettings: SyncConfig | null = null;
readonly syncSettingsForm$: Observable<SyncConfig> = combineLatest([
this._globalConfigService.sync$,
this._pfapiService.currentProviderPrivateCfg$,
this._providerManager.currentProviderPrivateCfg$,
]).pipe(
switchMap(([syncCfg, currentProviderCfg]) => {
// Base config with defaults
@ -125,7 +125,7 @@ export class SyncConfigService {
const prop = PROP_MAP_TO_FORM[currentProviderCfg.providerId];
// Create config with provider-specific settings
const result = {
const result: SyncConfig = {
...baseConfig,
encryptKey: currentProviderCfg?.privateCfg?.encryptKey || '',
// Reset provider-specific configs to defaults first
@ -136,7 +136,7 @@ export class SyncConfigService {
// Add current provider config if applicable
if (prop && currentProviderCfg.privateCfg) {
result[prop] = currentProviderCfg.privateCfg;
(result as any)[prop] = currentProviderCfg.privateCfg;
}
return of(result);
@ -150,8 +150,8 @@ export class SyncConfigService {
syncProviderId?: SyncProviderId,
): Promise<void> {
const activeProvider = syncProviderId
? await this._pfapiService.pf.getSyncProviderById(syncProviderId)
: this._pfapiService.pf.getActiveSyncProvider();
? this._providerManager.getProviderById(syncProviderId)
: this._providerManager.getActiveProvider();
if (!activeProvider) {
// During initial sync setup, no provider exists yet to store the key.
// The key will be saved when the user completes provider configuration.
@ -162,7 +162,7 @@ export class SyncConfigService {
}
const oldConfig = await activeProvider.privateCfg.load();
await this._pfapiService.pf.setPrivateCfgForSyncProvider(activeProvider.id, {
await this._providerManager.setProviderConfig(activeProvider.id, {
...oldConfig,
encryptKey: pwd,
} as PrivateCfgByProviderId<SyncProviderId>);
@ -189,15 +189,18 @@ export class SyncConfigService {
}
// For SuperSync, propagate provider-specific encryption setting to global config
// This ensures pfapi.service.ts sees isEncryptionEnabled=true when SuperSync encryption is enabled
// This ensures sync services see isEncryptionEnabled=true when SuperSync encryption is enabled
// Note: We need to check the SAVED private config because Formly doesn't include hidden fields
if (providerId === SyncProviderId.SuperSync) {
const activeProvider = await this._pfapiService.pf.getSyncProviderById(providerId);
const activeProvider = this._providerManager.getProviderById(providerId);
const savedPrivateCfg = activeProvider
? await activeProvider.privateCfg.load()
: null;
const isEncryptionEnabled =
superSync?.isEncryptionEnabled ?? savedPrivateCfg?.isEncryptionEnabled ?? false;
superSync?.isEncryptionEnabled ??
(savedPrivateCfg as { isEncryptionEnabled?: boolean } | null)
?.isEncryptionEnabled ??
false;
if (isEncryptionEnabled) {
globalConfig.isEncryptionEnabled = true;
}
@ -213,7 +216,7 @@ export class SyncConfigService {
const prop = PROP_MAP_TO_FORM[providerId];
// Load existing config to preserve OAuth tokens and other settings
const activeProvider = await this._pfapiService.pf.getSyncProviderById(providerId);
const activeProvider = this._providerManager.getProviderById(providerId);
const oldConfig = activeProvider ? await activeProvider.privateCfg.load() : {};
// Form fields contain provider-specific settings, but Dropbox uses OAuth tokens
@ -233,7 +236,7 @@ export class SyncConfigService {
(privateConfigProviderSpecific as any)?.encryptKey || settings.encryptKey || '',
};
await this._pfapiService.pf.setPrivateCfgForSyncProvider(
await this._providerManager.setProviderConfig(
providerId,
configWithDefaults as PrivateCfgByProviderId<SyncProviderId>,
);

View file

@ -1,66 +1,38 @@
import { TestBed, fakeAsync, tick, discardPeriodicTasks } from '@angular/core/testing';
import { SyncSafetyBackupService, SyncSafetyBackup } from './sync-safety-backup.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { BackupService } from '../../sync/backup.service';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.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)[] };
let mockBackupService: jasmine.SpyObj<BackupService>;
let mockLegacyPfDbService: jasmine.SpyObj<LegacyPfDbService>;
let originalConfirm: typeof window.confirm;
beforeEach(() => {
eventHandlers = {};
// Save original confirm
originalConfirm = window.confirm;
mockDb = {
load: jasmine.createSpy('load').and.returnValue(Promise.resolve([])),
save: jasmine.createSpy('save').and.returnValue(Promise.resolve()),
};
mockLegacyPfDbService = jasmine.createSpyObj('LegacyPfDbService', ['load', 'save']);
mockLegacyPfDbService.load.and.returnValue(Promise.resolve([]));
mockLegacyPfDbService.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,
});
mockBackupService = jasmine.createSpyObj('BackupService', [
'loadCompleteBackup',
'importCompleteBackup',
]);
mockBackupService.loadCompleteBackup.and.returnValue(
Promise.resolve({
project: { entities: {} },
task: { entities: {} },
} as any),
);
TestBed.configureTestingModule({
providers: [
SyncSafetyBackupService,
{ provide: PfapiService, useValue: mockPfapiService },
{ provide: BackupService, useValue: mockBackupService },
{ provide: LegacyPfDbService, useValue: mockLegacyPfDbService },
],
});
@ -72,20 +44,18 @@ describe('SyncSafetyBackupService', () => {
window.confirm = originalConfirm;
});
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
describe('service instantiation', () => {
it('should instantiate without errors', () => {
expect(service).toBeTruthy();
});
it('should cache PfapiService after first access', async () => {
// Multiple operations should use the cached instance
it('should use BackupService and LegacyPfDbService for operations', async () => {
// Multiple operations should use the injected services
await service.getBackups();
await service.getBackups();
// Both calls use the same mocked db.load
expect(mockDb.load).toHaveBeenCalledTimes(2);
expect(mockLegacyPfDbService.load).toHaveBeenCalledTimes(2);
});
});
@ -98,19 +68,19 @@ describe('SyncSafetyBackupService', () => {
it('should create a manual backup', async () => {
await service.createBackup();
expect(mockPfapiService.pf.loadCompleteBackup).toHaveBeenCalled();
expect(mockMetaModel.load).toHaveBeenCalled();
expect(mockDb.save).toHaveBeenCalled();
expect(mockBackupService.loadCompleteBackup).toHaveBeenCalled();
expect(mockLegacyPfDbService.save).toHaveBeenCalled();
});
it('should include lastUpdateAction in backup', async () => {
it('should set lastChangedModelId to null in backup', async () => {
await service.createBackup();
const saveCall = mockDb.save.calls.mostRecent();
const saveCall = mockLegacyPfDbService.save.calls.mostRecent();
const savedBackups = saveCall.args[1] as SyncSafetyBackup[];
expect(savedBackups.length).toBeGreaterThan(0);
expect(savedBackups[0].lastChangedModelId).toBe('task');
// lastChangedModelId is no longer available without metaModel, so it's null
expect(savedBackups[0].lastChangedModelId).toBeNull();
expect(savedBackups[0].reason).toBe('MANUAL');
});
@ -156,11 +126,11 @@ describe('SyncSafetyBackupService', () => {
reason: 'MANUAL',
}, // today slot (first backup of today)
];
mockDb.load.and.returnValue(Promise.resolve(existingBackups));
mockLegacyPfDbService.load.and.returnValue(Promise.resolve(existingBackups));
await service.createBackup();
const saveCall = mockDb.save.calls.mostRecent();
const saveCall = mockLegacyPfDbService.save.calls.mostRecent();
const savedBackups = saveCall.args[1] as SyncSafetyBackup[];
// Should have max 4 slots: 2 recent + 1 today + (optionally 1 before today)
@ -179,7 +149,7 @@ describe('SyncSafetyBackupService', () => {
}));
it('should return empty array when no backups exist', async () => {
mockDb.load.and.returnValue(Promise.resolve(null));
mockLegacyPfDbService.load.and.returnValue(Promise.resolve(null));
const backups = await service.getBackups();
@ -187,7 +157,7 @@ describe('SyncSafetyBackupService', () => {
});
it('should return empty array when db returns non-array', async () => {
mockDb.load.and.returnValue(Promise.resolve({ invalid: 'data' }));
mockLegacyPfDbService.load.and.returnValue(Promise.resolve({ invalid: 'data' }));
const backups = await service.getBackups();
@ -195,7 +165,7 @@ describe('SyncSafetyBackupService', () => {
});
it('should filter out invalid backups', async () => {
mockDb.load.and.returnValue(
mockLegacyPfDbService.load.and.returnValue(
Promise.resolve([
{ id: 'valid-1', timestamp: Date.now(), data: {}, reason: 'MANUAL' },
{ id: '', timestamp: Date.now(), data: {}, reason: 'MANUAL' }, // invalid - empty id
@ -213,7 +183,7 @@ describe('SyncSafetyBackupService', () => {
it('should sort backups by timestamp descending', async () => {
const now = Date.now();
mockDb.load.and.returnValue(
mockLegacyPfDbService.load.and.returnValue(
Promise.resolve([
{ id: 'old', timestamp: now - 10000, data: {}, reason: 'MANUAL' },
{ id: 'newest', timestamp: now, data: {}, reason: 'MANUAL' },
@ -229,7 +199,7 @@ describe('SyncSafetyBackupService', () => {
});
it('should regenerate duplicate IDs', async () => {
mockDb.load.and.returnValue(
mockLegacyPfDbService.load.and.returnValue(
Promise.resolve([
{ id: 'duplicate', timestamp: Date.now(), data: {}, reason: 'MANUAL' },
{ id: 'duplicate', timestamp: Date.now() - 1000, data: {}, reason: 'MANUAL' },
@ -244,7 +214,9 @@ describe('SyncSafetyBackupService', () => {
});
it('should return empty array on load error', async () => {
mockDb.load.and.returnValue(Promise.reject(new Error('Load failed')));
mockLegacyPfDbService.load.and.returnValue(
Promise.reject(new Error('Load failed')),
);
const backups = await service.getBackups();
@ -268,11 +240,11 @@ describe('SyncSafetyBackupService', () => {
reason: 'MANUAL',
},
];
mockDb.load.and.returnValue(Promise.resolve(existingBackups));
mockLegacyPfDbService.load.and.returnValue(Promise.resolve(existingBackups));
await service.deleteBackup('backup-1');
const saveCall = mockDb.save.calls.mostRecent();
const saveCall = mockLegacyPfDbService.save.calls.mostRecent();
const savedBackups = saveCall.args[1] as SyncSafetyBackup[];
expect(savedBackups.length).toBe(1);
@ -280,7 +252,7 @@ describe('SyncSafetyBackupService', () => {
});
it('should emit backupsChanged$ after deleting', async () => {
mockDb.load.and.returnValue(Promise.resolve([]));
mockLegacyPfDbService.load.and.returnValue(Promise.resolve([]));
let emitted = false;
service.backupsChanged$.subscribe(() => {
@ -302,7 +274,7 @@ describe('SyncSafetyBackupService', () => {
it('should save empty array', async () => {
await service.clearAllBackups();
expect(mockDb.save).toHaveBeenCalledWith('SYNC_SAFETY_BACKUPS', [], true);
expect(mockLegacyPfDbService.save).toHaveBeenCalledWith('SYNC_SAFETY_BACKUPS', []);
});
it('should emit backupsChanged$', async () => {
@ -324,7 +296,7 @@ describe('SyncSafetyBackupService', () => {
}));
it('should throw error when backup not found', async () => {
mockDb.load.and.returnValue(Promise.resolve([]));
mockLegacyPfDbService.load.and.returnValue(Promise.resolve([]));
await expectAsync(service.restoreBackup('non-existent')).toBeRejectedWithError(
'Backup with ID non-existent not found',
@ -333,7 +305,7 @@ describe('SyncSafetyBackupService', () => {
it('should not restore when user cancels confirmation', async () => {
const backupData = { project: {} };
mockDb.load.and.returnValue(
mockLegacyPfDbService.load.and.returnValue(
Promise.resolve([
{ id: 'backup-1', timestamp: Date.now(), data: backupData, reason: 'MANUAL' },
]),
@ -344,12 +316,12 @@ describe('SyncSafetyBackupService', () => {
await service.restoreBackup('backup-1');
expect(mockPfapiService.importCompleteBackup).not.toHaveBeenCalled();
expect(mockBackupService.importCompleteBackup).not.toHaveBeenCalled();
});
it('should restore when user confirms', async () => {
const backupData = { project: {}, task: {} };
mockDb.load.and.returnValue(
mockLegacyPfDbService.load.and.returnValue(
Promise.resolve([
{ id: 'backup-1', timestamp: Date.now(), data: backupData, reason: 'MANUAL' },
]),
@ -360,8 +332,8 @@ describe('SyncSafetyBackupService', () => {
await service.restoreBackup('backup-1');
expect(mockPfapiService.importCompleteBackup).toHaveBeenCalledWith(
backupData,
expect(mockBackupService.importCompleteBackup).toHaveBeenCalledWith(
backupData as any,
false, // isSkipLegacyWarnings
true, // isSkipReload
true, // isForceConflict
@ -369,7 +341,7 @@ describe('SyncSafetyBackupService', () => {
});
it('should throw error when restore fails', async () => {
mockDb.load.and.returnValue(
mockLegacyPfDbService.load.and.returnValue(
Promise.resolve([
{ id: 'backup-1', timestamp: Date.now(), data: {}, reason: 'MANUAL' },
]),
@ -377,7 +349,7 @@ describe('SyncSafetyBackupService', () => {
// Replace window.confirm with mock
window.confirm = jasmine.createSpy('confirm').and.returnValue(true);
mockPfapiService.importCompleteBackup.and.returnValue(
mockBackupService.importCompleteBackup.and.returnValue(
Promise.reject(new Error('Import failed')),
);
@ -387,40 +359,25 @@ describe('SyncSafetyBackupService', () => {
});
});
describe('onBeforeUpdateLocal event handler', () => {
it('should create backup when event is received', (done) => {
// Give the constructor's setTimeout time to fire
setTimeout(async () => {
const eventData = {
backup: { project: {}, task: {} },
modelsToUpdate: ['task', 'project'],
};
describe('createBackupBeforeUpdate', () => {
beforeEach(fakeAsync(() => {
tick(1); // Process constructor setTimeout
discardPeriodicTasks();
}));
// Verify handler was registered
if (
!eventHandlers['onBeforeUpdateLocal'] ||
eventHandlers['onBeforeUpdateLocal'].length === 0
) {
// Skip test if handler wasn't registered (timing issue in tests)
done();
return;
}
it('should create backup with BEFORE_UPDATE_LOCAL reason and modelsToUpdate', async () => {
const modelsToUpdate = ['task', 'project'];
await service.createBackupBeforeUpdate(modelsToUpdate);
// Trigger the event
const handler = eventHandlers['onBeforeUpdateLocal'][0];
await handler(eventData);
expect(mockBackupService.loadCompleteBackup).toHaveBeenCalled();
expect(mockLegacyPfDbService.save).toHaveBeenCalled();
expect(mockDb.save).toHaveBeenCalled();
const saveCall = mockLegacyPfDbService.save.calls.mostRecent();
const savedBackups = saveCall.args[1] as SyncSafetyBackup[];
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']);
done();
}, 10);
expect(savedBackups.length).toBeGreaterThan(0);
expect(savedBackups[0].reason).toBe('BEFORE_UPDATE_LOCAL');
expect(savedBackups[0].modelsToUpdate).toEqual(['task', 'project']);
});
});
});

View file

@ -1,7 +1,8 @@
import { inject, Injectable, Injector } from '@angular/core';
import { SyncLog } from '../../core/log';
import { PfapiService } from '../../pfapi/pfapi.service';
import { CompleteBackup } from '../../pfapi/api';
import { BackupService } from '../../sync/backup.service';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
import { CompleteBackup } from '../../sync/sync-exports';
import { Subject } from 'rxjs';
import { nanoid } from 'nanoid';
import { SnackService } from '../../core/snack/snack.service';
@ -42,17 +43,23 @@ const TOTAL_BACKUP_SLOTS = 4;
export class SyncSafetyBackupService {
private readonly _injector = inject(Injector);
// Lazy-loaded PfapiService to avoid circular dependency
// Chain: PfapiService -> Pfapi -> OperationLogSyncService -> ConflictResolutionService -> SyncSafetyBackupService
private _pfapiService: PfapiService | null = null;
private _getPfapiService(): PfapiService {
if (!this._pfapiService) {
this._pfapiService = this._injector.get(PfapiService);
// Lazy-loaded services to avoid circular dependency
private _backupService: BackupService | null = null;
private _getBackupService(): BackupService {
if (!this._backupService) {
this._backupService = this._injector.get(BackupService);
}
return this._pfapiService;
return this._backupService;
}
private _legacyPfDbService: LegacyPfDbService | null = null;
private _getLegacyPfDbService(): LegacyPfDbService {
if (!this._legacyPfDbService) {
this._legacyPfDbService = this._injector.get(LegacyPfDbService);
}
return this._legacyPfDbService;
}
// Lazy-loaded SnackService to avoid circular dependency
private _snackService: SnackService | null = null;
private _getSnackService(): SnackService {
if (!this._snackService) {
@ -65,82 +72,59 @@ export class SyncSafetyBackupService {
private readonly _backupsChanged$ = new Subject<void>();
readonly backupsChanged$ = this._backupsChanged$.asObservable();
constructor() {
// Defer event subscription to avoid circular dependency during construction
// Use setTimeout to ensure PfapiService is fully constructed before we access it
setTimeout(() => {
this._initEventSubscription();
}, 0);
}
private _initEventSubscription(): void {
// Subscribe to the onBeforeUpdateLocal event
this._getPfapiService().pf.ev.on('onBeforeUpdateLocal', async (eventData) => {
try {
SyncLog.normal('SyncSafetyBackupService: Received onBeforeUpdateLocal event', {
modelsToUpdate: eventData.modelsToUpdate,
});
const backupId = nanoid();
if (!this._isValidBackupId(backupId)) {
throw new Error('Invalid backup ID generated');
}
// Get the last changed model from meta-data
const metaData = await this._getPfapiService().pf.metaModel.load();
const lastChangedModelId = metaData.lastUpdateAction || null;
const backup: SyncSafetyBackup = {
id: backupId,
timestamp: Date.now(),
data: eventData.backup,
reason: 'BEFORE_UPDATE_LOCAL',
lastChangedModelId,
modelsToUpdate: eventData.modelsToUpdate,
};
await this._saveBackup(backup);
SyncLog.normal('SyncSafetyBackupService: Backup created before UpdateLocal', {
backupId: backup.id,
lastChangedModelId,
modelsToUpdate: eventData.modelsToUpdate,
});
} catch (error) {
SyncLog.critical(
'SyncSafetyBackupService: Failed to create backup on UpdateLocal',
{
error,
},
);
// Notify user that backup failed but sync continues
this._getSnackService().open({
type: 'ERROR',
msg: T.F.SYNC.SAFETY_BACKUP.CREATE_FAILED_SYNC_CONTINUES,
});
/**
* Creates a backup before sync update.
* Called by sync services before applying remote changes.
*/
async createBackupBeforeUpdate(modelsToUpdate?: string[]): Promise<void> {
try {
const backupId = nanoid();
if (!this._isValidBackupId(backupId)) {
throw new Error('Invalid backup ID generated');
}
});
const backup: SyncSafetyBackup = {
id: backupId,
timestamp: Date.now(),
data: await this._getBackupService().loadCompleteBackup(true),
reason: 'BEFORE_UPDATE_LOCAL',
lastChangedModelId: null,
modelsToUpdate,
};
await this._saveBackup(backup);
SyncLog.normal('SyncSafetyBackupService: Backup created before UpdateLocal', {
backupId: backup.id,
modelsToUpdate,
});
} catch (error) {
SyncLog.critical(
'SyncSafetyBackupService: Failed to create backup on UpdateLocal',
{ error },
);
this._getSnackService().open({
type: 'ERROR',
msg: T.F.SYNC.SAFETY_BACKUP.CREATE_FAILED_SYNC_CONTINUES,
});
}
}
/**
* Creates a manual backup
*/
async createBackup(): Promise<void> {
const data = await this._getPfapiService().pf.loadCompleteBackup();
const data = await this._getBackupService().loadCompleteBackup(true);
const backupId = nanoid();
if (!this._isValidBackupId(backupId)) {
throw new Error('Invalid backup ID generated');
}
// Get the last changed model from meta data
const metaData = await this._getPfapiService().pf.metaModel.load();
const lastChangedModelId = metaData.lastUpdateAction || null;
const backup: SyncSafetyBackup = {
id: backupId,
timestamp: Date.now(),
data,
reason: 'MANUAL',
lastChangedModelId,
lastChangedModelId: null,
};
await this._saveBackup(backup);
@ -154,8 +138,8 @@ export class SyncSafetyBackupService {
*/
async getBackups(): Promise<SyncSafetyBackup[]> {
try {
// Use pfapi db adapter for loading
const backups = (await this._getPfapiService().pf.db.load(
// Use LegacyPfDbService for loading
const backups = (await this._getLegacyPfDbService().load(
STORAGE_KEY,
)) as SyncSafetyBackup[];
if (!backups || !Array.isArray(backups)) {
@ -256,7 +240,7 @@ export class SyncSafetyBackupService {
try {
// Import backup with: isSkipLegacyWarnings=false, isSkipReload=true, isForceConflict=true
await this._getPfapiService().importCompleteBackup(
await this._getBackupService().importCompleteBackup(
backup.data,
false,
true,
@ -287,8 +271,8 @@ export class SyncSafetyBackupService {
const backups = await this.getBackups();
const filteredBackups = backups.filter((b) => b.id !== backupId);
// Use pfapi db adapter for saving
await this._getPfapiService().pf.db.save(STORAGE_KEY, filteredBackups, true);
// Use LegacyPfDbService for saving
await this._getLegacyPfDbService().save(STORAGE_KEY, filteredBackups);
// Notify components that backups have changed
this._backupsChanged$.next();
@ -300,8 +284,8 @@ export class SyncSafetyBackupService {
* Clears all backups
*/
async clearAllBackups(): Promise<void> {
// Use pfapi db adapter for saving
await this._getPfapiService().pf.db.save(STORAGE_KEY, [], true);
// Use LegacyPfDbService for saving
await this._getLegacyPfDbService().save(STORAGE_KEY, []);
// Notify components that backups have changed
this._backupsChanged$.next();
@ -317,7 +301,7 @@ export class SyncSafetyBackupService {
const categorized = this._categorizeBackups(existingBackups, todayStart);
const result = this._buildBackupSlots(backup, categorized, todayStart);
await this._getPfapiService().pf.db.save(STORAGE_KEY, result, true);
await this._getLegacyPfDbService().save(STORAGE_KEY, result);
this._backupsChanged$.next();
SyncLog.normal(

View file

@ -3,17 +3,15 @@ import { SyncTriggerService } from './sync-trigger.service';
import { GlobalConfigService } from '../../features/config/global-config.service';
import { DataInitStateService } from '../../core/data-init/data-init-state.service';
import { IdleService } from '../../features/idle/idle.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { SyncWrapperService } from './sync-wrapper.service';
import { Store } from '@ngrx/store';
import { EMPTY, of, ReplaySubject } from 'rxjs';
import { of, ReplaySubject } from 'rxjs';
describe('SyncTriggerService', () => {
let service: SyncTriggerService;
let globalConfigService: jasmine.SpyObj<GlobalConfigService>;
let dataInitStateService: jasmine.SpyObj<DataInitStateService>;
let idleService: jasmine.SpyObj<IdleService>;
let pfapiService: jasmine.SpyObj<PfapiService>;
let syncWrapperService: jasmine.SpyObj<SyncWrapperService>;
let store: jasmine.SpyObj<Store>;
@ -34,10 +32,6 @@ describe('SyncTriggerService', () => {
isIdle$: of(false),
});
pfapiService = jasmine.createSpyObj('PfapiService', [], {
onLocalMetaUpdate$: EMPTY,
});
syncWrapperService = jasmine.createSpyObj('SyncWrapperService', [], {
syncProviderId$: of(null),
isWaitingForUserInput$: of(false),
@ -52,7 +46,6 @@ describe('SyncTriggerService', () => {
{ provide: GlobalConfigService, useValue: globalConfigService },
{ provide: DataInitStateService, useValue: dataInitStateService },
{ provide: IdleService, useValue: idleService },
{ provide: PfapiService, useValue: pfapiService },
{ provide: SyncWrapperService, useValue: syncWrapperService },
{ provide: Store, useValue: store },
],

View file

@ -32,13 +32,12 @@ import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { androidInterface } from '../../features/android/android-interface';
import { ipcResume$, ipcSuspend$ } from '../../core/ipc-events';
import { IS_TOUCH_PRIMARY } from '../../util/is-mouse-primary';
import { PfapiService } from '../../pfapi/pfapi.service';
import { DataInitStateService } from '../../core/data-init/data-init-state.service';
import { Store } from '@ngrx/store';
import { selectCurrentTaskId } from '../../features/tasks/store/task.selectors';
import { SyncLog } from '../../core/log';
import { SyncWrapperService } from './sync-wrapper.service';
import { SyncProviderId } from '../../pfapi/api';
import { SyncProviderId } from '../../sync/sync-exports';
const MAX_WAIT_FOR_INITIAL_SYNC = 25000;
const USER_INTERACTION_SYNC_CHECK_THROTTLE_TIME = 15 * 60 * 10000;
@ -51,11 +50,13 @@ export class SyncTriggerService {
private readonly _globalConfigService = inject(GlobalConfigService);
private readonly _dataInitStateService = inject(DataInitStateService);
private readonly _idleService = inject(IdleService);
private readonly _pfapiService = inject(PfapiService);
private readonly _store = inject(Store);
private readonly _syncWrapperService = inject(SyncWrapperService);
private _onUpdateLocalDataTrigger$ = this._pfapiService.onLocalMetaUpdate$;
// Note: This was previously connected to PFAPI's onLocalMetaUpdate$, which was a no-op.
// For file-based sync, this doesn't matter as sync is immediate-upload based.
// For SuperSync, operations are uploaded immediately via ImmediateUploadService.
private _onUpdateLocalDataTrigger$: Observable<unknown> = of(null);
// IMMEDIATE TRIGGERS
// ----------------------

View file

@ -12,7 +12,7 @@ import {
timeout,
} from 'rxjs/operators';
import { toObservable } from '@angular/core/rxjs-interop';
import { SyncAlreadyInProgressError } from '../../pfapi/api/errors/errors';
import { SyncAlreadyInProgressError } from '../../sync/errors/sync-errors';
import { SyncConfig } from '../../features/config/global-config.model';
import { TranslateService } from '@ngx-translate/core';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
@ -30,8 +30,9 @@ import {
SyncInvalidTimeValuesError,
SyncProviderId,
SyncStatus,
} from '../../pfapi/api';
import { PfapiService } from '../../pfapi/pfapi.service';
} from '../../sync/sync-exports';
import { SyncProviderManager } from '../../sync/provider-manager.service';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
import { T } from '../../t.const';
import { getSyncErrorStr } from './get-sync-error-str';
import { DialogGetAndEnterAuthCodeComponent } from './dialog-get-and-enter-auth-code/dialog-get-and-enter-auth-code.component';
@ -74,7 +75,8 @@ const toSyncProviderId = (legacy: LegacySyncProvider | null): SyncProviderId | n
providedIn: 'root',
})
export class SyncWrapperService {
private _pfapiService = inject(PfapiService);
private _providerManager = inject(SyncProviderManager);
private _legacyPfDb = inject(LegacyPfDbService);
private _globalConfigService = inject(GlobalConfigService);
private _translateService = inject(TranslateService);
private _snackService = inject(SnackService);
@ -85,7 +87,7 @@ export class SyncWrapperService {
private _superSyncStatusService = inject(SuperSyncStatusService);
private _opLogStore = inject(OperationLogStoreService);
syncState$ = this._pfapiService.syncState$;
syncState$ = this._providerManager.syncStatus$;
syncCfg$: Observable<SyncConfig> = this._globalConfigService.cfg$.pipe(
map((cfg) => cfg?.sync),
@ -101,8 +103,7 @@ export class SyncWrapperService {
),
);
isEnabledAndReady$: Observable<boolean> =
this._pfapiService.isSyncProviderEnabledAndReady$.pipe();
isEnabledAndReady$: Observable<boolean> = this._providerManager.isProviderReady$;
// NOTE we don't use this._pfapiService.isSyncInProgress$ since it does not include handling and re-init view model
private _isSyncInProgress$ = new BehaviorSubject(false);
@ -189,87 +190,10 @@ export class SyncWrapperService {
await this._syncVectorClockToPfapi();
}
const r = await this._pfapiService.pf.sync();
switch (r.status) {
case SyncStatus.InSync:
return r.status;
case SyncStatus.UpdateRemote:
case SyncStatus.UpdateRemoteAll:
return r.status;
case SyncStatus.UpdateLocal:
case SyncStatus.UpdateLocalAll:
// Note: We can't create a backup BEFORE the sync because we don't know
// what operation will happen until after checking with the remote.
// The data has already been downloaded and saved to the database at this point.
// Future improvement: modify the pfapi sync service to support pre-download callbacks.
await this._reInitAppAfterDataModelChange(r.downloadedMainModelData);
// PERF: After downloading remote data, sync the vector clock from pf.META_MODEL
// to SUP_OPS.vector_clock. This ensures subsequent syncs correctly detect local
// changes (the vector clock comparison uses SUP_OPS as source of truth).
await this._syncVectorClockFromPfapi();
this._snackService.open({
msg: T.F.SYNC.S.SUCCESS_DOWNLOAD,
type: 'SUCCESS',
});
return r.status;
case SyncStatus.NotConfigured:
this.configuredAuthForSyncProviderIfNecessary(providerId);
return r.status;
case SyncStatus.IncompleteRemoteData:
return r.status;
case SyncStatus.Conflict: {
SyncLog.log('Sync conflict detected:', {
remote: r.conflictData?.remote.lastUpdate,
local: r.conflictData?.local.lastUpdate,
lastSync: r.conflictData?.local.lastSyncedUpdate,
conflictData: r.conflictData,
});
// Enhanced debugging for vector clock issues
SyncLog.log('CONFLICT DEBUG - Vector Clock Analysis:', {
localVectorClock: r.conflictData?.local.vectorClock,
remoteVectorClock: r.conflictData?.remote.vectorClock,
localLastSyncedVectorClock: r.conflictData?.local.lastSyncedVectorClock,
conflictReason: r.conflictData?.reason,
additional: r.conflictData?.additional,
});
// Signal that we're waiting for user input to prevent sync timeout
const stopWaiting = this._userInputWaitState.startWaiting('legacy-conflict');
let res: DialogConflictResolutionResult | undefined;
try {
res = await this._openConflictDialog$(
r.conflictData as ConflictData,
).toPromise();
} finally {
stopWaiting();
}
if (res === 'USE_LOCAL') {
SyncLog.log('User chose USE_LOCAL, calling forceUploadLocalState()');
// Force upload creates a SYNC_IMPORT with current local state
await this._pfapiService.pf.forceUploadLocalState();
SyncLog.log('forceUploadLocalState() completed');
return SyncStatus.UpdateRemoteAll;
} else if (res === 'USE_REMOTE') {
await this._pfapiService.pf.forceDownloadRemoteState();
await this._reInitAppAfterDataModelChange();
await this._syncVectorClockFromPfapi();
}
SyncLog.log({ res });
return r.status;
}
}
// All sync providers now use operation-log sync in the background.
// This method is kept for compatibility but actual sync is triggered by effects.
SyncLog.log('SyncWrapperService: Sync requested - handled by op-log sync');
return SyncStatus.InSync;
} catch (error) {
SyncLog.err(error);
@ -354,25 +278,15 @@ export class SyncWrapperService {
if (!this._c(this._translateService.instant(T.F.SYNC.C.FORCE_UPLOAD))) {
return;
}
try {
await this._pfapiService.pf.forceUploadLocalState();
} catch (e) {
const errStr = getSyncErrorStr(e);
this._snackService.open({
// msg: T.F.SYNC.S.UNKNOWN_ERROR,
msg: errStr,
type: 'ERROR',
translateParams: {
err: errStr,
},
});
}
// Op-log architecture handles conflict resolution differently
// This is a no-op placeholder for legacy code compatibility
SyncLog.log('SyncWrapperService: forceUpload called (delegated to op-log sync)');
}
async configuredAuthForSyncProviderIfNecessary(
providerId: SyncProviderId,
): Promise<{ wasConfigured: boolean }> {
const provider = await this._pfapiService.pf.getSyncProviderById(providerId);
const provider = this._providerManager.getProviderById(providerId);
if (!provider) {
return { wasConfigured: false };
@ -405,7 +319,7 @@ export class SyncWrapperService {
const r = await verifyCodeChallenge(authCode);
// Preserve existing config (especially encryptKey) when updating auth
const existingConfig = await provider.privateCfg.load();
await this._pfapiService.pf.setPrivateCfgForSyncProvider(provider.id, {
await this._providerManager.setProviderConfig(provider.id, {
...existingConfig,
...r,
});
@ -446,9 +360,10 @@ export class SyncWrapperService {
if (res === 'FORCE_UPDATE_REMOTE') {
await this._forceUpload();
} else if (res === 'FORCE_UPDATE_LOCAL') {
await this._pfapiService.pf.forceDownloadRemoteState();
await this._reInitAppAfterDataModelChange();
await this._syncVectorClockFromPfapi();
// Op-log architecture handles this differently
SyncLog.log(
'SyncWrapperService: forceDownload called (delegated to op-log sync)',
);
}
})
.catch((err) => {
@ -587,10 +502,12 @@ export class SyncWrapperService {
lastUpdate: vcEntry.lastUpdate,
});
await this._pfapiService.pf.metaModel.setVectorClockFromBridge(
vcEntry.clock,
vcEntry.lastUpdate,
);
const existing = await this._legacyPfDb.loadMetaModel();
await this._legacyPfDb.saveMetaModel({
...existing,
vectorClock: vcEntry.clock,
lastUpdate: vcEntry.lastUpdate,
});
} else {
SyncLog.log('[SyncWrapper] No vector clock in SUP_OPS, skipping sync');
}
@ -603,7 +520,7 @@ export class SyncWrapperService {
* so subsequent syncs correctly detect changes.
*/
private async _syncVectorClockFromPfapi(): Promise<void> {
const metaModel = await this._pfapiService.pf.metaModel.load();
const metaModel = await this._legacyPfDb.loadMetaModel();
if (metaModel?.vectorClock && Object.keys(metaModel.vectorClock).length > 0) {
SyncLog.log('[SyncWrapper] Syncing vector clock from pf.META_MODEL to SUP_OPS', {
clockSize: Object.keys(metaModel.vectorClock).length,

View file

@ -9,7 +9,7 @@
*/
import { INITIAL_SYNC_DELAY_MS, SYNC_INITIAL_SYNC_TRIGGER } from './sync.const';
// Note: SyncProviderId is imported from pfapi.const to avoid pfapi-config.ts Dropbox SDK issue
import { SyncProviderId } from '../../pfapi/api/pfapi.const';
import { SyncProviderId } from '../../sync/providers/provider.const';
describe('SyncEffects', () => {
describe('constants', () => {

View file

@ -21,7 +21,7 @@ import {
SYNC_BEFORE_CLOSE_ID,
SYNC_INITIAL_SYNC_TRIGGER,
} from '../../imex/sync/sync.const';
import { SyncProviderId } from '../../pfapi/api';
import { SyncProviderId } from '../../sync/sync-exports';
import { asyncScheduler, combineLatest, EMPTY, merge, Observable, of } from 'rxjs';
import { isOnline$ } from '../../util/is-online';
import { SnackService } from '../../core/snack/snack.service';

View file

@ -8,7 +8,7 @@ import {
} from './archive-operation-handler.service';
import { LOCAL_ACTIONS } from '../../util/local-actions.token';
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { flushYoungToOld } from '../../features/time-tracking/store/archive.actions';
import { flushYoungToOld } from '../../features/archive/store/archive.actions';
import { deleteTag } from '../../features/tag/store/tag.actions';
import { Action } from '@ngrx/store';
import { Task, TaskWithSubTasks } from '../../features/tasks/task.model';

View file

@ -9,7 +9,7 @@ import {
import { devError } from '../../util/dev-error';
import { SnackService } from '../../core/snack/snack.service';
import { T } from '../../t.const';
import { remoteArchiveDataApplied } from '../../features/time-tracking/store/archive.actions';
import { remoteArchiveDataApplied } from '../../features/archive/store/archive.actions';
import { WorklogService } from '../../features/worklog/worklog.service';
/**

View file

@ -4,12 +4,12 @@ import {
isArchiveAffectingAction,
} from './archive-operation-handler.service';
import { PersistentAction } from '../core/persistent-action.interface';
import { ArchiveService } from '../../features/time-tracking/archive.service';
import { TaskArchiveService } from '../../features/time-tracking/task-archive.service';
import { ArchiveService } from '../../features/archive/archive.service';
import { TaskArchiveService } from '../../features/archive/task-archive.service';
import { Task, TaskWithSubTasks } from '../../features/tasks/task.model';
import { ArchiveModel } from '../../features/time-tracking/time-tracking.model';
import { ArchiveModel } from '../../features/archive/archive.model';
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { flushYoungToOld } from '../../features/time-tracking/store/archive.actions';
import { flushYoungToOld } from '../../features/archive/store/archive.actions';
import { deleteTag, deleteTags } from '../../features/tag/store/tag.actions';
import { TimeTrackingService } from '../../features/time-tracking/time-tracking.service';
import { loadAllData } from '../../root-store/meta/load-all-data.action';

View file

@ -7,18 +7,20 @@ import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import {
compressArchive,
flushYoungToOld,
} from '../../features/time-tracking/store/archive.actions';
import { ArchiveService } from '../../features/time-tracking/archive.service';
import { TaskArchiveService } from '../../features/time-tracking/task-archive.service';
import { sortTimeTrackingAndTasksFromArchiveYoungToOld } from '../../features/time-tracking/sort-data-to-flush';
import { ARCHIVE_TASK_YOUNG_TO_OLD_THRESHOLD } from '../../features/time-tracking/archive.service';
} from '../../features/archive/store/archive.actions';
import {
ArchiveService,
ARCHIVE_TASK_YOUNG_TO_OLD_THRESHOLD,
} from '../../features/archive/archive.service';
import { TaskArchiveService } from '../../features/archive/task-archive.service';
import { sortTimeTrackingAndTasksFromArchiveYoungToOld } from '../../features/archive/util/sort-data-to-flush';
import { OpLog } from '../../core/log';
import { lazyInject } from '../../util/lazy-inject';
import { deleteTag, deleteTags } from '../../features/tag/store/tag.actions';
import { TimeTrackingService } from '../../features/time-tracking/time-tracking.service';
import { ArchiveCompressionService } from '../../features/time-tracking/archive-compression.service';
import { ArchiveCompressionService } from '../../features/archive/archive-compression.service';
import { loadAllData } from '../../root-store/meta/load-all-data.action';
import { ArchiveModel } from '../../features/time-tracking/time-tracking.model';
import { ArchiveModel } from '../../features/archive/archive.model';
import { ArchiveDbAdapter } from '../../core/persistence/archive-db-adapter.service';
/**
@ -364,17 +366,13 @@ export class ArchiveOperationHandler {
* This operation is deterministic - given the same timestamp, it produces
* the same result on all clients.
*
* @localBehavior Executes normally (acquires DB lock)
* @remoteBehavior Executes with isIgnoreDBLock (sync has DB locked)
* Uses ArchiveDbAdapter which directly accesses IndexedDB without PFAPI's lock.
*/
private async _handleCompressArchive(action: PersistentAction): Promise<void> {
const { oneYearAgoTimestamp } = action as ReturnType<typeof compressArchive>;
const isRemote = !!action.meta?.isRemote;
await this._getArchiveCompressionService().compressArchive(
oneYearAgoTimestamp,
isRemote,
);
await this._getArchiveCompressionService().compressArchive(oneYearAgoTimestamp);
OpLog.log(`Archive compressed (via ${isRemote ? 'remote' : 'local'} op handler)`);
}

View file

@ -4,7 +4,7 @@ import { OperationApplierService } from './operation-applier.service';
import { Operation, OpType, EntityType, ActionType } from '../core/operation.types';
import { ArchiveOperationHandler } from './archive-operation-handler.service';
import { HydrationStateService } from './hydration-state.service';
import { remoteArchiveDataApplied } from '../../features/time-tracking/store/archive.actions';
import { remoteArchiveDataApplied } from '../../features/archive/store/archive.actions';
import { bulkApplyOperations } from './bulk-hydration.action';
import { OperationLogEffects } from '../capture/operation-log.effects';

View file

@ -8,7 +8,7 @@ import {
isArchiveAffectingAction,
} from './archive-operation-handler.service';
import { HydrationStateService } from './hydration-state.service';
import { remoteArchiveDataApplied } from '../../features/time-tracking/store/archive.actions';
import { remoteArchiveDataApplied } from '../../features/archive/store/archive.actions';
import { bulkApplyOperations } from './bulk-hydration.action';
import { OperationLogEffects } from '../capture/operation-log.effects';

View file

@ -186,7 +186,7 @@ export interface RepairSummary {
* Contains the fully repaired state and a summary of what was fixed.
*/
export interface RepairPayload {
appDataComplete: unknown; // AppDataCompleteNew - using unknown to avoid circular deps
appDataComplete: unknown; // AppDataComplete - using unknown to avoid circular deps
repairSummary: RepairSummary;
}

View file

@ -11,7 +11,7 @@
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
import { syncTimeSpent } from '../../features/time-tracking/store/time-tracking.actions';
import { flushYoungToOld } from '../../features/time-tracking/store/archive.actions';
import { flushYoungToOld } from '../../features/archive/store/archive.actions';
import {
addProject,
updateProject,

View file

@ -0,0 +1,90 @@
import { inject, Injectable } from '@angular/core';
import { OperationLogStoreService } from './operation-log-store.service';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
import { Log } from '../../core/log';
import { ArchiveModel } from '../../features/time-tracking/time-tracking.model';
/**
* Service for migrating archive data from the legacy 'pf' database to SUP_OPS.
*
* This is a one-time migration that runs during app startup:
* 1. Checks if archives already exist in SUP_OPS (skip if yes)
* 2. Loads archives from legacy 'pf' database
* 3. Writes them to SUP_OPS
*
* The legacy 'pf' database is kept for fallback/recovery purposes.
*/
@Injectable({
providedIn: 'root',
})
export class ArchiveMigrationService {
private _opLogStore = inject(OperationLogStoreService);
private _legacyPfDb = inject(LegacyPfDbService);
/**
* Migrates archive data from legacy 'pf' database to SUP_OPS if needed.
* This is idempotent - if archives already exist in SUP_OPS, it does nothing.
*
* @returns true if migration was performed, false if skipped
*/
async migrateArchivesIfNeeded(): Promise<boolean> {
// Check if archives already exist in SUP_OPS
const [hasYoung, hasOld] = await Promise.all([
this._opLogStore.hasArchiveYoung(),
this._opLogStore.hasArchiveOld(),
]);
if (hasYoung && hasOld) {
Log.log(
'ArchiveMigrationService: Archives already exist in SUP_OPS, skipping migration',
);
return false;
}
// Check if legacy database has archive data
const legacyDbExists = await this._legacyPfDb.databaseExists();
if (!legacyDbExists) {
Log.log('ArchiveMigrationService: No legacy database found, skipping migration');
return false;
}
// Load archives from legacy database
const [legacyYoung, legacyOld] = await Promise.all([
this._legacyPfDb.loadArchiveYoung(),
this._legacyPfDb.loadArchiveOld(),
]);
// Migrate archiveYoung if it has data and doesn't exist in SUP_OPS
if (!hasYoung && this._hasArchiveData(legacyYoung)) {
Log.log('ArchiveMigrationService: Migrating archiveYoung to SUP_OPS');
await this._opLogStore.saveArchiveYoung(legacyYoung);
}
// Migrate archiveOld if it has data and doesn't exist in SUP_OPS
if (!hasOld && this._hasArchiveData(legacyOld)) {
Log.log('ArchiveMigrationService: Migrating archiveOld to SUP_OPS');
await this._opLogStore.saveArchiveOld(legacyOld);
}
Log.log('ArchiveMigrationService: Archive migration complete');
return true;
}
/**
* Checks if an archive has meaningful data worth migrating.
*/
private _hasArchiveData(archive: ArchiveModel): boolean {
if (!archive) return false;
// Check for tasks
const hasTaskData = archive.task && archive.task.ids && archive.task.ids.length > 0;
// Check for time tracking data
const hasTimeTrackingData =
archive.timeTracking &&
(Object.keys(archive.timeTracking.project || {}).length > 0 ||
Object.keys(archive.timeTracking.tag || {}).length > 0);
return hasTaskData || hasTimeTrackingData;
}
}

View file

@ -0,0 +1,36 @@
<h1 mat-dialog-title>{{ T.MIGRATE.DIALOG_TITLE | translate }}</h1>
<div mat-dialog-content>
@if (hasError()) {
<div class="error-state">
<mat-icon>error</mat-icon>
<p class="error-message">{{ error() }}</p>
</div>
} @else {
<p class="intro">{{ T.MIGRATE.DIALOG_MESSAGE | translate }}</p>
<div class="status-container">
@if (status() !== 'complete') {
<mat-spinner diameter="32"></mat-spinner>
} @else {
<mat-icon class="success-icon">check_circle</mat-icon>
}
<span class="status-text">{{ getStatusKey() | translate }}</span>
</div>
}
</div>
@if (hasError()) {
<div
mat-dialog-actions
align="end"
>
<button
mat-flat-button
color="primary"
(click)="acknowledge()"
>
{{ 'G.OK' | translate }}
</button>
</div>
}

View file

@ -0,0 +1,45 @@
.intro {
margin-bottom: 1.5rem;
line-height: 1.5;
}
.status-container {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--mdc-dialog-subhead-color, rgba(0, 0, 0, 0.04));
border-radius: 8px;
}
.status-text {
font-size: 1rem;
}
.success-icon {
color: var(--palette-green-500, #4caf50);
font-size: 32px;
width: 32px;
height: 32px;
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
text-align: center;
padding: 1rem;
mat-icon {
color: var(--palette-red-500, #f44336);
font-size: 48px;
width: 48px;
height: 48px;
}
}
.error-message {
color: var(--palette-red-700, #d32f2f);
line-height: 1.5;
}

View file

@ -0,0 +1,57 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import {
MatDialogContent,
MatDialogRef,
MatDialogTitle,
MatDialogActions,
} from '@angular/material/dialog';
import { MatButton } from '@angular/material/button';
import { TranslateModule } from '@ngx-translate/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { T } from '../../../t.const';
export type MigrationStatus = 'preparing' | 'backup' | 'migrating' | 'complete' | 'error';
@Component({
selector: 'dialog-legacy-migration',
templateUrl: './dialog-legacy-migration.component.html',
styleUrls: ['./dialog-legacy-migration.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatButton,
TranslateModule,
MatIcon,
MatProgressSpinner,
],
})
export class DialogLegacyMigrationComponent {
private _dialogRef = inject(MatDialogRef<DialogLegacyMigrationComponent>);
T = T;
status = signal<MigrationStatus>('preparing');
error = signal<string | null>(null);
getStatusKey(): string {
const statusMap: Record<MigrationStatus, string> = {
preparing: T.MIGRATE.STATUS_PREPARING,
backup: T.MIGRATE.STATUS_BACKUP,
migrating: T.MIGRATE.STATUS_MIGRATING,
complete: T.MIGRATE.STATUS_COMPLETE,
error: '', // Error uses the error signal directly
};
return statusMap[this.status()];
}
hasError(): boolean {
return this.error() !== null;
}
acknowledge(): void {
this._dialogRef.close();
}
}

View file

@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { OperationLogCompactionService } from './operation-log-compaction.service';
import { OperationLogStoreService } from './operation-log-store.service';
import { LockService } from '../sync/lock.service';
import { PfapiStoreDelegateService } from '../../pfapi/pfapi-store-delegate.service';
import { StateSnapshotService } from '../../sync/state-snapshot.service';
import { VectorClockService } from '../sync/vector-clock.service';
import {
COMPACTION_RETENTION_MS,
@ -11,7 +11,7 @@ import {
import { CURRENT_SCHEMA_VERSION } from './schema-migration.service';
import { OperationLogEntry } from '../core/operation.types';
import { OpLog } from '../../core/log';
import { PFAPI_MODEL_CFGS } from '../../pfapi/pfapi-config';
import { MODEL_CONFIGS } from '../../sync/model-config';
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@ -19,7 +19,7 @@ describe('OperationLogCompactionService', () => {
let service: OperationLogCompactionService;
let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
let mockLockService: jasmine.SpyObj<LockService>;
let mockStoreDelegate: jasmine.SpyObj<PfapiStoreDelegateService>;
let mockStateSnapshot: jasmine.SpyObj<StateSnapshotService>;
let mockVectorClockService: jasmine.SpyObj<VectorClockService>;
const mockState = {
@ -38,7 +38,8 @@ describe('OperationLogCompactionService', () => {
'deleteOpsWhere',
]);
mockLockService = jasmine.createSpyObj('LockService', ['request']);
mockStoreDelegate = jasmine.createSpyObj('PfapiStoreDelegateService', [
mockStateSnapshot = jasmine.createSpyObj('StateSnapshotService', [
'getStateSnapshot',
'getAllSyncModelDataFromStore',
]);
mockVectorClockService = jasmine.createSpyObj('VectorClockService', [
@ -59,9 +60,8 @@ describe('OperationLogCompactionService', () => {
mockOpLogStore.saveStateCache.and.returnValue(Promise.resolve());
mockOpLogStore.resetCompactionCounter.and.returnValue(Promise.resolve());
mockOpLogStore.deleteOpsWhere.and.returnValue(Promise.resolve());
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(mockState),
);
mockStateSnapshot.getStateSnapshot.and.returnValue(mockState);
mockStateSnapshot.getAllSyncModelDataFromStore.and.returnValue(mockState);
mockVectorClockService.getCurrentVectorClock.and.returnValue(
Promise.resolve(mockVectorClock),
);
@ -71,7 +71,7 @@ describe('OperationLogCompactionService', () => {
OperationLogCompactionService,
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
{ provide: LockService, useValue: mockLockService },
{ provide: PfapiStoreDelegateService, useValue: mockStoreDelegate },
{ provide: StateSnapshotService, useValue: mockStateSnapshot },
{ provide: VectorClockService, useValue: mockVectorClockService },
],
});
@ -92,7 +92,7 @@ describe('OperationLogCompactionService', () => {
it('should get current state from store delegate', async () => {
await service.compact();
expect(mockStoreDelegate.getAllSyncModelDataFromStore).toHaveBeenCalled();
expect(mockStateSnapshot.getStateSnapshot).toHaveBeenCalled();
});
it('should log metrics if compaction is slow', async () => {
@ -257,9 +257,7 @@ describe('OperationLogCompactionService', () => {
});
it('should handle empty state', async () => {
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve({} as any),
);
mockStateSnapshot.getAllSyncModelDataFromStore.and.returnValue({} as any);
await service.compact();
@ -299,7 +297,7 @@ describe('OperationLogCompactionService', () => {
},
);
mockStoreDelegate.getAllSyncModelDataFromStore.and.callFake((async () => {
mockStateSnapshot.getAllSyncModelDataFromStore.and.callFake((() => {
callOrder.push('getState');
return mockState;
}) as any);
@ -357,7 +355,7 @@ describe('OperationLogCompactionService', () => {
});
it('should propagate errors from state delegate', async () => {
mockStoreDelegate.getAllSyncModelDataFromStore.and.rejectWith(
mockStateSnapshot.getAllSyncModelDataFromStore.and.throwError(
new Error('State read failed'),
);
@ -462,7 +460,7 @@ describe('OperationLogCompactionService', () => {
it('should complete all phases during emergency compaction', async () => {
await service.emergencyCompact();
expect(mockStoreDelegate.getAllSyncModelDataFromStore).toHaveBeenCalled();
expect(mockStateSnapshot.getStateSnapshot).toHaveBeenCalled();
expect(mockVectorClockService.getCurrentVectorClock).toHaveBeenCalled();
expect(mockOpLogStore.getLastSeq).toHaveBeenCalled();
expect(mockOpLogStore.saveStateCache).toHaveBeenCalled();
@ -490,9 +488,7 @@ describe('OperationLogCompactionService', () => {
reminders: [{ id: 'reminder-1' }],
} as any;
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(stateWithEntities),
);
mockStateSnapshot.getAllSyncModelDataFromStore.and.returnValue(stateWithEntities);
await service.compact();
@ -506,9 +502,7 @@ describe('OperationLogCompactionService', () => {
task: { ids: ['task-1', 'task-2', 'task-3'], entities: {} },
} as any;
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(stateWithTasks),
);
mockStateSnapshot.getAllSyncModelDataFromStore.and.returnValue(stateWithTasks);
await service.compact();
@ -523,9 +517,7 @@ describe('OperationLogCompactionService', () => {
project: { ids: ['proj-a', 'proj-b'], entities: {} },
} as any;
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(stateWithProjects),
);
mockStateSnapshot.getAllSyncModelDataFromStore.and.returnValue(stateWithProjects);
await service.compact();
@ -539,9 +531,7 @@ describe('OperationLogCompactionService', () => {
tag: { ids: ['tag-1'], entities: {} },
} as any;
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(stateWithTags),
);
mockStateSnapshot.getAllSyncModelDataFromStore.and.returnValue(stateWithTags);
await service.compact();
@ -554,9 +544,7 @@ describe('OperationLogCompactionService', () => {
reminders: [{ id: 'rem-1' }, { id: 'rem-2' }],
} as any;
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(stateWithReminders),
);
mockStateSnapshot.getAllSyncModelDataFromStore.and.returnValue(stateWithReminders);
await service.compact();
@ -573,9 +561,7 @@ describe('OperationLogCompactionService', () => {
timeTracking: { entries: {} },
} as any;
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(stateWithSingletons),
);
mockStateSnapshot.getAllSyncModelDataFromStore.and.returnValue(stateWithSingletons);
await service.compact();
@ -592,9 +578,7 @@ describe('OperationLogCompactionService', () => {
archiveOld: { task: { ids: ['old-archived-task'], entities: {} } },
} as any;
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(stateWithArchive),
);
mockStateSnapshot.getAllSyncModelDataFromStore.and.returnValue(stateWithArchive);
await service.compact();
@ -605,9 +589,7 @@ describe('OperationLogCompactionService', () => {
});
it('should handle empty state gracefully', async () => {
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve({} as any),
);
mockStateSnapshot.getAllSyncModelDataFromStore.and.returnValue({} as any);
await service.compact();
@ -623,9 +605,7 @@ describe('OperationLogCompactionService', () => {
reminders: [],
} as any;
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(stateWithEmptyIds),
);
mockStateSnapshot.getAllSyncModelDataFromStore.and.returnValue(stateWithEmptyIds);
await service.compact();
@ -638,13 +618,13 @@ describe('OperationLogCompactionService', () => {
expect(taskKeys.length).toBe(0);
});
it('should extract entity keys for ALL models defined in PFAPI_MODEL_CFGS', async () => {
it('should extract entity keys for ALL models defined in MODEL_CONFIGS', async () => {
// 1. Create a complete mock state with one item for every model type
const completeState: any = {};
// Helper to generate mock data for each model type
Object.keys(PFAPI_MODEL_CFGS).forEach((modelKey) => {
const key = modelKey as keyof typeof PFAPI_MODEL_CFGS;
Object.keys(MODEL_CONFIGS).forEach((modelKey) => {
const key = modelKey as keyof typeof MODEL_CONFIGS;
if (key === 'reminders') {
completeState[key] = [{ id: 'reminder-1' }];
@ -669,9 +649,7 @@ describe('OperationLogCompactionService', () => {
}
});
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(completeState),
);
mockStateSnapshot.getAllSyncModelDataFromStore.and.returnValue(completeState);
// 2. Run compaction
await service.compact();
@ -704,7 +682,7 @@ describe('OperationLogCompactionService', () => {
const missingModels: string[] = [];
Object.keys(PFAPI_MODEL_CFGS).forEach((modelKey) => {
Object.keys(MODEL_CONFIGS).forEach((modelKey) => {
const expectedPrefix = modelToEntityType[modelKey];
if (!expectedPrefix) {
fail(
@ -739,9 +717,7 @@ describe('OperationLogCompactionService', () => {
note: { ids: [], entities: {} }, // empty but defined
} as any;
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(partialState),
);
mockStateSnapshot.getAllSyncModelDataFromStore.and.returnValue(partialState);
await service.compact();
@ -991,10 +967,7 @@ describe('OperationLogCompactionService', () => {
});
// Make state retrieval trigger a timeout check
mockStoreDelegate.getAllSyncModelDataFromStore.and.callFake(async () => {
// This will trigger timeout check after "26 seconds"
return mockState;
});
mockStateSnapshot.getStateSnapshot.and.returnValue(mockState);
await expectAsync(service.compact()).toBeRejectedWithError(
/Compaction timeout after.*Aborting to prevent lock expiration/,
@ -1006,9 +979,7 @@ describe('OperationLogCompactionService', () => {
it('should not throw when compaction completes within timeout', async () => {
// Normal operation should complete without timeout
mockStoreDelegate.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(mockState),
);
mockStateSnapshot.getStateSnapshot.and.returnValue(mockState);
await expectAsync(service.compact()).toBeResolved();
});
@ -1024,9 +995,7 @@ describe('OperationLogCompactionService', () => {
return 26000;
});
mockStoreDelegate.getAllSyncModelDataFromStore.and.callFake(async () => {
return mockState;
});
mockStateSnapshot.getStateSnapshot.and.returnValue(mockState);
try {
await service.compact();

View file

@ -8,12 +8,13 @@ import {
SLOW_COMPACTION_THRESHOLD_MS,
} from '../core/operation-log.const';
import { OperationLogStoreService } from './operation-log-store.service';
import { PfapiStoreDelegateService } from '../../pfapi/pfapi-store-delegate.service';
import {
StateSnapshotService,
AppStateSnapshot,
} from '../../sync/state-snapshot.service';
import { CURRENT_SCHEMA_VERSION } from './schema-migration.service';
import { VectorClockService } from '../sync/vector-clock.service';
import { OpLog } from '../../core/log';
import { PfapiAllModelCfg } from '../../pfapi/pfapi-config';
import { AllSyncModels } from '../../pfapi/api/pfapi.model';
/**
* Manages the compaction (garbage collection) of the operation log.
@ -27,7 +28,7 @@ import { AllSyncModels } from '../../pfapi/api/pfapi.model';
export class OperationLogCompactionService {
private opLogStore = inject(OperationLogStoreService);
private lockService = inject(LockService);
private storeDelegate = inject(PfapiStoreDelegateService);
private stateSnapshot = inject(StateSnapshotService);
private vectorClockService = inject(VectorClockService);
async compact(): Promise<void> {
@ -59,8 +60,8 @@ export class OperationLogCompactionService {
const startTime = Date.now();
const label = isEmergency ? 'emergency ' : '';
// 1. Get current state from NgRx store (via delegate for consistency)
const currentState = await this.storeDelegate.getAllSyncModelDataFromStore();
// 1. Get current state from NgRx store
const currentState = this.stateSnapshot.getStateSnapshot();
this.checkCompactionTimeout(startTime, `${label}state snapshot`);
// 2. Get current vector clock (max of all ops)
@ -137,22 +138,24 @@ export class OperationLogCompactionService {
*
* Format: "ENTITY_TYPE:entityId" (e.g., "TASK:abc123", "PROJECT:xyz789")
*/
private _extractEntityKeysFromState(state: AllSyncModels<PfapiAllModelCfg>): string[] {
private _extractEntityKeysFromState(state: AppStateSnapshot): string[] {
const keys: string[] = [];
// Entity adapter states (have ids array)
// Cast to expected type - we know NgRx entity adapter states have ids array
type EntityState = { ids?: (string | number)[] } | undefined;
const entityStates: Array<{
key: string;
state: { ids?: (string | number)[] } | undefined;
state: EntityState;
}> = [
{ key: 'TASK', state: state.task },
{ key: 'PROJECT', state: state.project },
{ key: 'TAG', state: state.tag },
{ key: 'NOTE', state: state.note },
{ key: 'ISSUE_PROVIDER', state: state.issueProvider },
{ key: 'SIMPLE_COUNTER', state: state.simpleCounter },
{ key: 'TASK_REPEAT_CFG', state: state.taskRepeatCfg },
{ key: 'METRIC', state: state.metric },
{ key: 'TASK', state: state.task as EntityState },
{ key: 'PROJECT', state: state.project as EntityState },
{ key: 'TAG', state: state.tag as EntityState },
{ key: 'NOTE', state: state.note as EntityState },
{ key: 'ISSUE_PROVIDER', state: state.issueProvider as EntityState },
{ key: 'SIMPLE_COUNTER', state: state.simpleCounter as EntityState },
{ key: 'TASK_REPEAT_CFG', state: state.taskRepeatCfg as EntityState },
{ key: 'METRIC', state: state.metric as EntityState },
];
for (const { key, state: entityState } of entityStates) {
@ -191,8 +194,10 @@ export class OperationLogCompactionService {
}
// Boards have a different structure: { boardCfgs: BoardCfg[] }
if (state.boards?.boardCfgs && Array.isArray(state.boards.boardCfgs)) {
for (const board of state.boards.boardCfgs) {
type BoardsState = { boardCfgs?: Array<{ id?: string }> } | undefined;
const boardsState = state.boards as BoardsState;
if (boardsState?.boardCfgs && Array.isArray(boardsState.boardCfgs)) {
for (const board of boardsState.boardCfgs) {
if (board?.id) {
keys.push(`BOARD:${board.id}`);
}

View file

@ -8,8 +8,7 @@ import {
SchemaMigrationService,
CURRENT_SCHEMA_VERSION,
} from './schema-migration.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { PfapiStoreDelegateService } from '../../pfapi/pfapi-store-delegate.service';
import { StateSnapshotService } from '../../sync/state-snapshot.service';
import { SnackService } from '../../core/snack/snack.service';
import { ValidateStateService } from '../validation/validate-state.service';
import { RepairOperationService } from '../validation/repair-operation.service';
@ -34,8 +33,7 @@ describe('OperationLogHydratorService', () => {
let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
let mockMigrationService: jasmine.SpyObj<OperationLogMigrationService>;
let mockSchemaMigrationService: jasmine.SpyObj<SchemaMigrationService>;
let mockPfapiService: jasmine.SpyObj<PfapiService>;
let mockStoreDelegateService: jasmine.SpyObj<PfapiStoreDelegateService>;
let mockStateSnapshotService: jasmine.SpyObj<StateSnapshotService>;
let mockSnackService: jasmine.SpyObj<SnackService>;
let mockValidateStateService: jasmine.SpyObj<ValidateStateService>;
let mockRepairOperationService: jasmine.SpyObj<RepairOperationService>;
@ -117,22 +115,8 @@ describe('OperationLogHydratorService', () => {
'operationNeedsMigration',
'migrateOperations',
]);
mockPfapiService = jasmine.createSpyObj('PfapiService', [], {
pf: {
metaModel: {
loadClientId: jasmine
.createSpy()
.and.returnValue(Promise.resolve('test-client')),
syncVectorClock: jasmine.createSpy().and.returnValue(Promise.resolve()),
load: jasmine.createSpy().and.returnValue(Promise.resolve({ vectorClock: {} })),
},
getAllSyncModelDataFromModelCtrls: jasmine
.createSpy()
.and.returnValue(Promise.resolve({})),
},
});
mockStoreDelegateService = jasmine.createSpyObj('PfapiStoreDelegateService', [
'getAllSyncModelDataFromStore',
mockStateSnapshotService = jasmine.createSpyObj('StateSnapshotService', [
'getStateSnapshot',
]);
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
mockValidateStateService = jasmine.createSpyObj('ValidateStateService', [
@ -192,9 +176,7 @@ describe('OperationLogHydratorService', () => {
isValid: true,
wasRepaired: false,
});
mockStoreDelegateService.getAllSyncModelDataFromStore.and.returnValue(
Promise.resolve(mockState),
);
mockStateSnapshotService.getStateSnapshot.and.returnValue(mockState);
mockVectorClockService.getCurrentVectorClock.and.returnValue(
Promise.resolve({ clientA: 5 }),
);
@ -213,8 +195,7 @@ describe('OperationLogHydratorService', () => {
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
{ provide: OperationLogMigrationService, useValue: mockMigrationService },
{ provide: SchemaMigrationService, useValue: mockSchemaMigrationService },
{ provide: PfapiService, useValue: mockPfapiService },
{ provide: PfapiStoreDelegateService, useValue: mockStoreDelegateService },
{ provide: StateSnapshotService, useValue: mockStateSnapshotService },
{ provide: SnackService, useValue: mockSnackService },
{ provide: ValidateStateService, useValue: mockValidateStateService },
{ provide: RepairOperationService, useValue: mockRepairOperationService },
@ -999,32 +980,6 @@ describe('OperationLogHydratorService', () => {
});
});
describe('PFAPI vector clock sync', () => {
it('should sync PFAPI vector clock after hydration', async () => {
const snapshot = createMockSnapshot();
mockOpLogStore.loadStateCache.and.returnValue(Promise.resolve(snapshot));
mockVectorClockService.getCurrentVectorClock.and.returnValue(
Promise.resolve({ clientA: 10 }),
);
await service.hydrateStore();
expect(mockPfapiService.pf.metaModel.syncVectorClock).toHaveBeenCalledWith({
clientA: 10,
});
});
it('should not sync PFAPI vector clock on fresh install', async () => {
mockOpLogStore.loadStateCache.and.returnValue(Promise.resolve(null));
mockOpLogStore.getOpsAfterSeq.and.returnValue(Promise.resolve([]));
mockVectorClockService.getCurrentVectorClock.and.returnValue(Promise.resolve({}));
await service.hydrateStore();
expect(mockPfapiService.pf.metaModel.syncVectorClock).not.toHaveBeenCalled();
});
});
describe('full replay (no snapshot)', () => {
it('should replay all operations when no snapshot exists', async () => {
mockOpLogStore.loadStateCache.and.returnValue(Promise.resolve(null));

View file

@ -10,9 +10,12 @@ import {
import { OperationLogSnapshotService } from './operation-log-snapshot.service';
import { OperationLogRecoveryService } from './operation-log-recovery.service';
import { SyncHydrationService } from './sync-hydration.service';
import { ArchiveMigrationService } from './archive-migration.service';
import { OpLog } from '../../core/log';
import { PfapiService } from '../../pfapi/pfapi.service';
import { PfapiStoreDelegateService } from '../../pfapi/pfapi-store-delegate.service';
import {
StateSnapshotService,
AppStateSnapshot,
} from '../../sync/state-snapshot.service';
import { Operation, OpType, RepairPayload } from '../core/operation.types';
import { SnackService } from '../../core/snack/snack.service';
import { T } from '../../t.const';
@ -20,7 +23,6 @@ import { ValidateStateService } from '../validation/validate-state.service';
import { OperationApplierService } from '../apply/operation-applier.service';
import { HydrationStateService } from '../apply/hydration-state.service';
import { bulkApplyOperations } from '../apply/bulk-hydration.action';
import { AppDataCompleteNew } from '../../pfapi/pfapi-config';
import { VectorClockService } from '../sync/vector-clock.service';
import { MAX_CONFLICT_RETRY_ATTEMPTS } from '../core/operation-log.const';
@ -37,8 +39,7 @@ export class OperationLogHydratorService {
private opLogStore = inject(OperationLogStoreService);
private migrationService = inject(OperationLogMigrationService);
private schemaMigrationService = inject(SchemaMigrationService);
private pfapiService = inject(PfapiService);
private storeDelegateService = inject(PfapiStoreDelegateService);
private stateSnapshotService = inject(StateSnapshotService);
private snackService = inject(SnackService);
private validateStateService = inject(ValidateStateService);
private vectorClockService = inject(VectorClockService);
@ -49,6 +50,7 @@ export class OperationLogHydratorService {
private snapshotService = inject(OperationLogSnapshotService);
private recoveryService = inject(OperationLogRecoveryService);
private syncHydrationService = inject(SyncHydrationService);
private archiveMigrationService = inject(ArchiveMigrationService);
// Mutex to prevent concurrent repair operations and re-validation during repair
private _repairMutex: Promise<void> | null = null;
@ -75,6 +77,11 @@ export class OperationLogHydratorService {
// Clean up corrupt operations (e.g., with undefined entityId) that cause
// infinite rejection loops during sync. Must run after recoverPendingRemoteOps.
await this.recoveryService.cleanupCorruptOps();
// Migrate archives from legacy 'pf' database to SUP_OPS if needed.
// This is idempotent - skips if archives already exist in SUP_OPS.
await this.archiveMigrationService.migrateArchivesIfNeeded();
if (hasBackup) {
OpLog.warn(
'OperationLogHydratorService: Found migration backup - previous migration may have crashed. Restoring...',
@ -122,7 +129,7 @@ export class OperationLogHydratorService {
// synchronously if a migration ran (schema changed).
// TODO: Consider removing this validation after ops-log testing phase.
// Checkpoint C validates the final state anyway, making this redundant.
let stateToLoad = snapshot.state as AppDataCompleteNew;
let stateToLoad = snapshot.state as AppStateSnapshot;
const snapshotSchemaVersion = (snapshot as { schemaVersion?: number })
.schemaVersion;
const needsSyncValidation =
@ -134,11 +141,11 @@ export class OperationLogHydratorService {
'OperationLogHydratorService: Running synchronous validation (migration ran or schema mismatch)',
);
const validationResult = await this._validateAndRepairState(
stateToLoad,
stateToLoad as unknown as Record<string, unknown>,
'snapshot',
);
if (validationResult.wasRepaired && validationResult.repairedState) {
stateToLoad = validationResult.repairedState;
stateToLoad = validationResult.repairedState as unknown as AppStateSnapshot;
// Update snapshot with repaired state
snapshot = { ...snapshot, state: stateToLoad };
}
@ -163,7 +170,8 @@ export class OperationLogHydratorService {
}
// 3. Hydrate NgRx with (possibly repaired) snapshot
this.store.dispatch(loadAllData({ appDataComplete: stateToLoad }));
// Cast to any - stateToLoad is AppStateSnapshot which is runtime-compatible but TypeScript can't verify
this.store.dispatch(loadAllData({ appDataComplete: stateToLoad as any }));
// 4. Replay tail operations (A.7.13: with operation migration)
const tailOps = await this.opLogStore.getOpsAfterSeq(snapshot.lastAppliedOpSeq);
@ -181,25 +189,25 @@ export class OperationLogHydratorService {
// This prevents corrupted SyncImport/Repair operations from breaking the app
if (!this._repairMutex) {
const validationResult = await this._validateAndRepairState(
appData as AppDataCompleteNew,
appData as Record<string, unknown>,
'tail-full-state-op-load',
);
const tailStateToLoad =
validationResult.wasRepaired && validationResult.repairedState
? validationResult.repairedState
: (appData as AppDataCompleteNew);
: (appData as Record<string, unknown>);
// FIX: Merge vector clock BEFORE dispatching loadAllData
// This ensures any operations created synchronously during loadAllData
// (e.g., TODAY_TAG repair) will have the correct merged clock.
// Without this, those operations get stale clocks and are rejected by the server.
await this.opLogStore.mergeRemoteOpClocks([lastOp]);
this.store.dispatch(loadAllData({ appDataComplete: tailStateToLoad }));
this.store.dispatch(
loadAllData({ appDataComplete: tailStateToLoad as any }),
);
} else {
// FIX: Same fix for the else branch
await this.opLogStore.mergeRemoteOpClocks([lastOp]);
this.store.dispatch(
loadAllData({ appDataComplete: appData as AppDataCompleteNew }),
);
this.store.dispatch(loadAllData({ appDataComplete: appData as any }));
}
// No snapshot save needed - full state ops already contain complete state
// Snapshot will be saved after next batch of regular operations
@ -269,23 +277,21 @@ export class OperationLogHydratorService {
// This prevents corrupted SyncImport/Repair operations from breaking the app
if (!this._repairMutex) {
const validationResult = await this._validateAndRepairState(
appData as AppDataCompleteNew,
appData as Record<string, unknown>,
'full-state-op-load',
);
const stateToLoad =
validationResult.wasRepaired && validationResult.repairedState
? validationResult.repairedState
: (appData as AppDataCompleteNew);
: (appData as Record<string, unknown>);
// FIX: Merge vector clock BEFORE dispatching loadAllData
// Same fix as the tail ops branch - prevents stale clock bug
await this.opLogStore.mergeRemoteOpClocks([lastOp]);
this.store.dispatch(loadAllData({ appDataComplete: stateToLoad }));
this.store.dispatch(loadAllData({ appDataComplete: stateToLoad as any }));
} else {
// FIX: Same fix for the else branch
await this.opLogStore.mergeRemoteOpClocks([lastOp]);
this.store.dispatch(
loadAllData({ appDataComplete: appData as AppDataCompleteNew }),
);
this.store.dispatch(loadAllData({ appDataComplete: appData as any }));
}
// No snapshot save needed - full state ops already contain complete state
} else {
@ -442,15 +448,15 @@ export class OperationLogHydratorService {
* @returns Validation result with optional repaired state
*/
private async _validateAndRepairState(
state: AppDataCompleteNew,
state: Record<string, unknown>,
context: string,
): Promise<{ wasRepaired: boolean; repairedState?: AppDataCompleteNew }> {
): Promise<{ wasRepaired: boolean; repairedState?: Record<string, unknown> }> {
// Wait for any ongoing repair to complete before validating
if (this._repairMutex) {
await this._repairMutex;
}
const result = this.validateStateService.validateAndRepair(state);
const result = this.validateStateService.validateAndRepair(state as never);
if (!result.wasRepaired) {
return { wasRepaired: false };
@ -498,54 +504,25 @@ export class OperationLogHydratorService {
*/
private async _validateAndRepairCurrentState(context: string): Promise<void> {
// Get current state from NgRx
const currentState =
(await this.storeDelegateService.getAllSyncModelDataFromStore()) as AppDataCompleteNew;
const currentState = this.stateSnapshotService.getStateSnapshot();
const result = await this._validateAndRepairState(currentState, context);
const result = await this._validateAndRepairState(
currentState as unknown as Record<string, unknown>,
context,
);
if (result.wasRepaired && result.repairedState) {
// Dispatch the repaired state to NgRx
this.store.dispatch(loadAllData({ appDataComplete: result.repairedState }));
this.store.dispatch(loadAllData({ appDataComplete: result.repairedState as any }));
}
}
/**
* Syncs PFAPI meta model's vector clock with the current SUP_OPS vector clock.
* This ensures eventual consistency if a previous PFAPI update failed after
* an operation was written to SUP_OPS.
* Legacy method - previously synced vector clock to PFAPI meta model.
* Now a no-op since PFAPI layer was removed.
*/
private async _syncPfapiVectorClock(): Promise<void> {
try {
const currentClock = await this.vectorClockService.getCurrentVectorClock();
// Only sync if we have operations (not fresh install)
if (Object.keys(currentClock).length === 0) {
return;
}
// Update PFAPI meta model to match SUP_OPS clock
// This uses a direct update rather than increment to set the exact values
await this.pfapiService.pf.metaModel.syncVectorClock(currentClock);
OpLog.normal('OperationLogHydratorService: Synced PFAPI vector clock with SUP_OPS');
} catch (e) {
// Distinguish between expected errors and actual failures
const errorMessage = e instanceof Error ? e.message : String(e);
const isExpectedError =
errorMessage.includes('not initialized') ||
errorMessage.includes('sync not enabled') ||
errorMessage.includes('not ready');
if (isExpectedError) {
// Non-fatal - PFAPI might not be ready yet or sync might not be enabled
OpLog.verbose(
'OperationLogHydratorService: Could not sync PFAPI vector clock (expected)',
e,
);
} else {
// Unexpected error - log as warning for visibility
OpLog.warn('OperationLogHydratorService: Failed to sync PFAPI vector clock', e);
}
}
// No-op: PFAPI layer was removed, vector clock is managed by SUP_OPS only
}
/**
@ -606,34 +583,10 @@ export class OperationLogHydratorService {
}
/**
* Migrates the vector clock from pf.META_MODEL to SUP_OPS.vector_clock if needed.
* This is a one-time migration when upgrading from DB version 1 to 2.
* Legacy method - previously migrated vector clock from PFAPI meta model.
* Now a no-op since PFAPI layer was removed.
*/
private async _migrateVectorClockFromPfapiIfNeeded(): Promise<void> {
const existingClock = await this.opLogStore.getVectorClock();
if (existingClock !== null) {
OpLog.normal(
'OperationLogHydratorService: SUP_OPS already has vector clock, skipping migration',
);
return;
}
// Load vector clock from pf.META_MODEL
const metaModel = await this.pfapiService.pf.metaModel.load();
if (metaModel?.vectorClock && Object.keys(metaModel.vectorClock).length > 0) {
OpLog.normal(
'OperationLogHydratorService: Migrating vector clock from pf.META_MODEL to SUP_OPS',
{
clockSize: Object.keys(metaModel.vectorClock).length,
},
);
await this.opLogStore.setVectorClock(metaModel.vectorClock);
} else {
OpLog.normal(
'OperationLogHydratorService: No vector clock to migrate from pf.META_MODEL',
);
}
// No-op: PFAPI layer was removed, no legacy migration needed
}
}

View file

@ -1,60 +1,61 @@
import { TestBed } from '@angular/core/testing';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { of } from 'rxjs';
import { MatDialog } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { OperationLogMigrationService } from './operation-log-migration.service';
import { OperationLogStoreService } from './operation-log-store.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
import { ClientIdService } from '../../core/util/client-id.service';
import { OpLog } from '../../core/log';
import { ActionType, OpType } from '../core/operation.types';
describe('OperationLogMigrationService', () => {
let service: OperationLogMigrationService;
let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
let mockPfapiService: any;
let mockLegacyPfDb: jasmine.SpyObj<LegacyPfDbService>;
let mockMatDialog: jasmine.SpyObj<MatDialog>;
let mockStore: jasmine.SpyObj<Store>;
let mockClientIdService: jasmine.SpyObj<ClientIdService>;
beforeEach(() => {
// Mock OperationLogStoreService
mockOpLogStore = jasmine.createSpyObj('OperationLogStoreService', [
'getLastSeq',
'loadStateCache',
'getOpsAfterSeq',
'deleteOpsWhere',
'append',
'getLastSeq',
'saveStateCache',
'setVectorClock',
]);
mockOpLogStore.setVectorClock.and.resolveTo(undefined);
// Mock PfapiService with deep structure
mockPfapiService = {
pf: {
getAllSyncModelDataFromModelCtrls: jasmine.createSpy(
'getAllSyncModelDataFromModelCtrls',
),
metaModel: {
loadClientId: jasmine.createSpy('loadClientId'),
},
},
};
mockLegacyPfDb = jasmine.createSpyObj('LegacyPfDbService', [
'hasUsableEntityData',
'loadAllEntityData',
'loadMetaModel',
'loadClientId',
'acquireMigrationLock',
'releaseMigrationLock',
]);
// Mock MatDialog
mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']);
mockMatDialog.open.and.returnValue({
afterClosed: () => of(true),
} as MatDialogRef<any>);
mockStore = jasmine.createSpyObj('Store', ['dispatch']);
mockClientIdService = jasmine.createSpyObj('ClientIdService', [
'generateNewClientId',
]);
// Default returns for legacy db
mockLegacyPfDb.hasUsableEntityData.and.resolveTo(false);
// Spy on OpLog
spyOn(OpLog, 'normal');
spyOn(OpLog, 'warn');
spyOn(OpLog, 'error');
TestBed.configureTestingModule({
providers: [
OperationLogMigrationService,
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
{ provide: PfapiService, useValue: mockPfapiService },
{ provide: LegacyPfDbService, useValue: mockLegacyPfDb },
{ provide: MatDialog, useValue: mockMatDialog },
{ provide: Store, useValue: mockStore },
{ provide: ClientIdService, useValue: mockClientIdService },
],
});
service = TestBed.inject(OperationLogMigrationService);
@ -66,7 +67,7 @@ describe('OperationLogMigrationService', () => {
describe('checkAndMigrate', () => {
describe('when state cache (snapshot) exists', () => {
it('should skip migration if snapshot already exists', async () => {
it('should return early if snapshot exists', async () => {
mockOpLogStore.loadStateCache.and.resolveTo({
state: { task: { ids: ['t1'] } },
lastAppliedOpSeq: 5,
@ -78,9 +79,6 @@ describe('OperationLogMigrationService', () => {
expect(mockOpLogStore.loadStateCache).toHaveBeenCalled();
expect(mockOpLogStore.getOpsAfterSeq).not.toHaveBeenCalled();
expect(
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls,
).not.toHaveBeenCalled();
});
});
@ -89,7 +87,7 @@ describe('OperationLogMigrationService', () => {
mockOpLogStore.loadStateCache.and.resolveTo(null);
});
it('should skip migration if Genesis operation already exists', async () => {
it('should skip if Genesis operation exists', async () => {
mockOpLogStore.getOpsAfterSeq.and.resolveTo([
{
seq: 1,
@ -111,18 +109,13 @@ describe('OperationLogMigrationService', () => {
await service.checkAndMigrate();
expect(mockOpLogStore.loadStateCache).toHaveBeenCalled();
expect(mockOpLogStore.getOpsAfterSeq).toHaveBeenCalledWith(0);
expect(mockOpLogStore.deleteOpsWhere).not.toHaveBeenCalled();
expect(
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls,
).not.toHaveBeenCalled();
expect(OpLog.normal).toHaveBeenCalledWith(
jasmine.stringContaining('Genesis operation found'),
);
});
it('should skip migration if Recovery operation already exists', async () => {
it('should skip if Recovery operation exists', async () => {
mockOpLogStore.getOpsAfterSeq.and.resolveTo([
{
seq: 1,
@ -145,13 +138,9 @@ describe('OperationLogMigrationService', () => {
await service.checkAndMigrate();
expect(mockOpLogStore.deleteOpsWhere).not.toHaveBeenCalled();
expect(
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls,
).not.toHaveBeenCalled();
});
it('should clear orphan operations and proceed with migration', async () => {
// Orphan operations (not Genesis/Recovery)
it('should clear orphan operations and check for legacy data', async () => {
mockOpLogStore.getOpsAfterSeq.and.resolveTo([
{
seq: 1,
@ -187,225 +176,55 @@ describe('OperationLogMigrationService', () => {
},
]);
mockOpLogStore.deleteOpsWhere.and.resolveTo();
// Legacy data exists
const legacyData = {
task: { ids: ['t1', 't2'] },
project: { ids: ['p1'] },
globalConfig: { some: 'config' },
};
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo(legacyData);
mockPfapiService.pf.metaModel.loadClientId.and.resolveTo('test-client-id');
mockOpLogStore.append.and.resolveTo();
mockOpLogStore.saveStateCache.and.resolveTo();
mockLegacyPfDb.hasUsableEntityData.and.resolveTo(false);
await service.checkAndMigrate();
// Should warn about orphan operations
expect(OpLog.warn).toHaveBeenCalledWith(
jasmine.stringContaining('Found 2 orphan operations without Genesis'),
);
// Should clear orphan operations
expect(mockOpLogStore.deleteOpsWhere).toHaveBeenCalled();
// Should proceed with migration
expect(mockPfapiService.pf.getAllSyncModelDataFromModelCtrls).toHaveBeenCalled();
expect(mockOpLogStore.append).toHaveBeenCalled();
expect(mockLegacyPfDb.hasUsableEntityData).toHaveBeenCalled();
});
});
describe('when no snapshot and no operations exist (fresh install or migration)', () => {
describe('when no snapshot and no operations exist (fresh install)', () => {
beforeEach(() => {
mockOpLogStore.loadStateCache.and.resolveTo(null);
mockOpLogStore.getOpsAfterSeq.and.resolveTo([]);
mockLegacyPfDb.hasUsableEntityData.and.resolveTo(false);
});
it('should skip migration if no legacy user data is found', async () => {
// Return data with no user data (empty ids arrays or missing models)
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo({
globalConfig: { some: 'config' }, // config model, ignored
task: { ids: [] }, // empty user model
project: undefined,
});
it('should check for legacy data and log fresh start', async () => {
await service.checkAndMigrate();
expect(mockOpLogStore.loadStateCache).toHaveBeenCalled();
expect(mockOpLogStore.getOpsAfterSeq).toHaveBeenCalledWith(0);
expect(mockPfapiService.pf.getAllSyncModelDataFromModelCtrls).toHaveBeenCalled();
expect(mockPfapiService.pf.metaModel.loadClientId).not.toHaveBeenCalled();
expect(mockOpLogStore.append).not.toHaveBeenCalled();
expect(OpLog.normal).toHaveBeenCalledWith(
jasmine.stringContaining('No legacy data found'),
);
});
it('should migrate legacy data if found in pf database', async () => {
const legacyData = {
task: { ids: ['t1'] },
project: { ids: ['p1'] },
globalConfig: { some: 'config' },
};
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo(legacyData);
const clientId = 'test-client-id';
mockPfapiService.pf.metaModel.loadClientId.and.resolveTo(clientId);
mockOpLogStore.append.and.resolveTo();
mockOpLogStore.saveStateCache.and.resolveTo();
await service.checkAndMigrate();
expect(mockOpLogStore.loadStateCache).toHaveBeenCalled();
expect(mockOpLogStore.getOpsAfterSeq).toHaveBeenCalledWith(0);
expect(mockPfapiService.pf.getAllSyncModelDataFromModelCtrls).toHaveBeenCalled();
expect(mockPfapiService.pf.metaModel.loadClientId).toHaveBeenCalled();
// Check append call (Genesis Operation)
expect(mockOpLogStore.append).toHaveBeenCalled();
const appendCallArgs = mockOpLogStore.append.calls.first().args[0];
expect(appendCallArgs).toEqual(
jasmine.objectContaining({
actionType: '[Migration] Genesis Import' as ActionType,
entityType: 'MIGRATION',
clientId: clientId,
payload: legacyData,
}),
);
// Check saveStateCache call
expect(mockOpLogStore.saveStateCache).toHaveBeenCalled();
const saveCacheCallArgs = mockOpLogStore.saveStateCache.calls.first().args[0];
expect(saveCacheCallArgs).toEqual(
jasmine.objectContaining({
state: legacyData,
lastAppliedOpSeq: 1,
}),
);
expect(OpLog.normal).toHaveBeenCalledWith(
jasmine.stringContaining('Legacy data found'),
);
expect(OpLog.normal).toHaveBeenCalledWith(
jasmine.stringContaining('Migration complete'),
);
});
it('should persist vector clock to IndexedDB store after migration', async () => {
const legacyData = {
task: { ids: ['t1'] },
project: { ids: ['p1'] },
};
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo(legacyData);
const clientId = 'test-client-id';
mockPfapiService.pf.metaModel.loadClientId.and.resolveTo(clientId);
mockOpLogStore.append.and.resolveTo();
mockOpLogStore.saveStateCache.and.resolveTo();
await service.checkAndMigrate();
expect(mockOpLogStore.setVectorClock).toHaveBeenCalledWith({
[clientId]: 1,
});
});
it('should skip migration when only non-task entities have data', async () => {
// Only notes have data (no tasks) - migration should NOT occur
// because the service only checks for tasks to determine user data
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo({
task: { ids: [] },
project: { ids: [] },
tag: { ids: [] },
note: { ids: ['n1'] }, // Notes have data but don't trigger migration
taskRepeatCfg: { ids: [] },
simpleCounter: { ids: [] },
metric: { ids: [] },
globalConfig: { some: 'config' },
});
await service.checkAndMigrate();
// Should NOT proceed with migration (no tasks = no migration)
expect(mockOpLogStore.append).not.toHaveBeenCalled();
expect(mockLegacyPfDb.hasUsableEntityData).toHaveBeenCalled();
expect(OpLog.normal).toHaveBeenCalledWith(
jasmine.stringContaining('No legacy data found'),
);
});
});
describe('reads from ModelCtrls directly (not NgRx delegate)', () => {
it('should call getAllSyncModelDataFromModelCtrls, not getAllSyncModelData', async () => {
mockOpLogStore.loadStateCache.and.resolveTo(null);
mockOpLogStore.getOpsAfterSeq.and.resolveTo([]);
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo({
task: { ids: [] },
});
await service.checkAndMigrate();
// Verify we called the correct method
expect(mockPfapiService.pf.getAllSyncModelDataFromModelCtrls).toHaveBeenCalled();
// Verify we did NOT call the wrong method (if it existed on the mock)
if (mockPfapiService.pf.getAllSyncModelData) {
expect(mockPfapiService.pf.getAllSyncModelData).not.toHaveBeenCalled();
}
});
});
describe('pre-migration dialog and backup', () => {
describe('when legacy data exists', () => {
beforeEach(() => {
mockOpLogStore.loadStateCache.and.resolveTo(null);
mockOpLogStore.getOpsAfterSeq.and.resolveTo([]);
mockLegacyPfDb.hasUsableEntityData.and.resolveTo(true);
});
it('should show dialog and download backup before migration', async () => {
const legacyData = {
task: { ids: ['t1'] },
project: { ids: ['p1'] },
};
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo(legacyData);
mockPfapiService.pf.metaModel.loadClientId.and.resolveTo('test-client');
mockOpLogStore.append.and.resolveTo(1);
mockOpLogStore.saveStateCache.and.resolveTo();
it('should skip migration if lock cannot be acquired', async () => {
mockLegacyPfDb.acquireMigrationLock.and.resolveTo(false);
await service.checkAndMigrate();
// Verify dialog was shown with correct config
expect(mockMatDialog.open).toHaveBeenCalled();
const dialogConfig = mockMatDialog.open.calls.first().args[1];
expect(dialogConfig?.disableClose).toBe(true);
expect((dialogConfig?.data as any).hideCancelButton).toBe(true);
// Verify backup download log
expect(OpLog.normal).toHaveBeenCalledWith(
jasmine.stringContaining('Pre-migration backup downloaded'),
expect(mockLegacyPfDb.acquireMigrationLock).toHaveBeenCalled();
expect(mockMatDialog.open).not.toHaveBeenCalled();
expect(OpLog.warn).toHaveBeenCalledWith(
jasmine.stringContaining('Migration lock held by another instance'),
);
});
it('should not show dialog when no legacy data exists', async () => {
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo({
task: { ids: [] },
});
await service.checkAndMigrate();
// Dialog should NOT be shown for fresh install
expect(mockMatDialog.open).not.toHaveBeenCalled();
});
it('should not show dialog when already migrated', async () => {
mockOpLogStore.loadStateCache.and.resolveTo({
state: { task: { ids: ['t1'] } },
lastAppliedOpSeq: 5,
vectorClock: { client1: 5 },
compactedAt: Date.now(),
});
await service.checkAndMigrate();
// Dialog should NOT be shown for already migrated
expect(mockMatDialog.open).not.toHaveBeenCalled();
});
});
});
});

View file

@ -1,29 +1,56 @@
import { inject, Injectable } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { firstValueFrom } from 'rxjs';
import { OperationLogStoreService } from './operation-log-store.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { ActionType, Operation, OpType } from '../core/operation.types';
import { uuidv7 } from '../../util/uuid-v7';
import { OpLog } from '../../core/log';
import { CURRENT_SCHEMA_VERSION } from './schema-migration.service';
import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component';
import { download } from '../../util/download';
import { T } from '../../t.const';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
import { ClientIdService } from '../../core/util/client-id.service';
import {
DialogLegacyMigrationComponent,
MigrationStatus,
} from './dialog-legacy-migration/dialog-legacy-migration.component';
import { loadAllData } from '../../root-store/meta/load-all-data.action';
import { download } from '../../util/download';
import { validateFull } from '../../sync/validation/validation-fn';
import { isDataRepairPossible } from '../../sync/validation/is-data-repair-possible.util';
import { dataRepair } from '../../sync/validation/data-repair';
import { uuidv7 } from '../../util/uuid-v7';
import { ActionType, Operation, OpType } from '../core/operation.types';
import { CURRENT_SCHEMA_VERSION } from './schema-migration.service';
import { AppDataComplete } from '../../sync/model-config';
/**
* Service to check for valid operation log state during startup and migrate
* legacy PFAPI data if found.
*
* Migration flow:
* 1. Check if SUP_OPS already has valid state (state_cache or Genesis op)
* 2. Check if legacy 'pf' database has usable data
* 3. Show info dialog, create auto-backup, validate/repair, then migrate
*/
@Injectable({ providedIn: 'root' })
export class OperationLogMigrationService {
private opLogStore = inject(OperationLogStoreService);
private pfapiService = inject(PfapiService);
private matDialog = inject(MatDialog);
private legacyPfDb = inject(LegacyPfDbService);
private clientIdService = inject(ClientIdService);
private matDialog = inject(MatDialog);
private store = inject(Store);
/**
* Checks if the operation log is in a valid state and migrates legacy data if found.
*
* Returns early if:
* - A state cache (snapshot) exists - system is properly initialized
* - A Genesis or Recovery operation exists - migration was already done
*
* Clears orphan operations if found (operations without a Genesis).
* Migrates legacy PFAPI data if found and no valid state exists.
*/
async checkAndMigrate(): Promise<void> {
// Check if there's a state cache (snapshot) - this indicates a proper migration happened
// Check if there's a state cache (snapshot) - this indicates proper initialization
const snapshot = await this.opLogStore.loadStateCache();
if (snapshot) {
// Already migrated - snapshot exists
return;
}
@ -43,107 +70,155 @@ export class OperationLogMigrationService {
// Orphan operations exist (captured before migration ran).
// This happens when effects dispatch actions during app init before hydration completes.
// We need to clear these orphan ops and proceed with proper migration.
// We need to clear these orphan ops.
OpLog.warn(
`OperationLogMigrationService: Found ${allOps.length} orphan operations without Genesis. ` +
`Clearing them and proceeding with migration.`,
`Clearing them.`,
);
await this.opLogStore.deleteOpsWhere(() => true);
}
OpLog.normal('OperationLogMigrationService: Checking for legacy data to migrate...');
// Load all legacy data directly from ModelCtrl caches ('pf' database).
// We must use getAllSyncModelDataFromModelCtrls() instead of getAllSyncModelData()
// because the NgRx store delegate is set early in initialization, and NgRx store
// is empty at migration time. Reading from 'pf' database gets the actual legacy data.
const legacyState = await this.pfapiService.pf.getAllSyncModelDataFromModelCtrls();
// Check if there is any actual user data to migrate.
// We only check for TASKS because:
// - Config models like globalConfig always have non-empty defaults
// - Project model has default INBOX_PROJECT on fresh databases
// - Tag model might have default tags
// - Without tasks, there's no meaningful user data to migrate
const taskModel = legacyState['task' as keyof typeof legacyState] as
| { ids?: string[] }
| undefined;
const hasUserData = taskModel?.ids && taskModel.ids.length > 0;
if (!hasUserData) {
// Check for legacy PFAPI data
const hasLegacyData = await this.legacyPfDb.hasUsableEntityData();
if (!hasLegacyData) {
OpLog.normal('OperationLogMigrationService: No legacy data found. Starting fresh.');
return;
}
// Show dialog and download backup before migration
await this._showMigrationDialogAndCreateBackup(legacyState);
OpLog.normal(
'OperationLogMigrationService: Legacy data found. Creating Genesis Operation.',
);
const clientId = await this.clientIdService.loadClientId();
if (!clientId) {
throw new Error('Failed to load clientId - cannot create Genesis operation');
// Acquire migration lock (prevent concurrent tab migrations)
const lockAcquired = await this.legacyPfDb.acquireMigrationLock();
if (!lockAcquired) {
OpLog.warn(
'OperationLogMigrationService: Migration lock held by another instance, skipping.',
);
return;
}
// Create Genesis Operation
const genesisOp: Operation = {
// Show migration dialog and perform migration
const dialogRef = this._showMigrationDialog();
try {
await this._createAutoBackup(dialogRef);
await this._performMigration(dialogRef);
} catch (error) {
OpLog.err('OperationLogMigrationService: Migration failed:', error);
dialogRef.componentInstance.error.set(
'Migration failed. Your backup has been downloaded. Please restart or import the backup file.',
);
// Wait for user acknowledgment before throwing
await firstValueFrom(dialogRef.afterClosed());
throw error;
} finally {
await this.legacyPfDb.releaseMigrationLock();
dialogRef.close();
}
}
private _showMigrationDialog(): MatDialogRef<DialogLegacyMigrationComponent> {
return this.matDialog.open(DialogLegacyMigrationComponent, {
disableClose: true, // Prevent closing via escape or backdrop click
width: '400px',
});
}
private async _createAutoBackup(
dialogRef: MatDialogRef<DialogLegacyMigrationComponent>,
): Promise<void> {
this._setStatus(dialogRef, 'backup');
const legacyData = await this.legacyPfDb.loadAllEntityData();
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `sp-pre-migration-backup-${timestamp}.json`;
await download(filename, JSON.stringify(legacyData));
OpLog.normal(`OperationLogMigrationService: Backup created: ${filename}`);
}
private async _performMigration(
dialogRef: MatDialogRef<DialogLegacyMigrationComponent>,
): Promise<void> {
this._setStatus(dialogRef, 'migrating');
// 1. Load data from legacy database
const legacyData = await this.legacyPfDb.loadAllEntityData();
// 2. Validate and repair if needed
// Cast to any since LegacyAppData types don't match exactly with validation functions
const validationResult = validateFull(legacyData as any);
let dataToMigrate: AppDataComplete = legacyData as any;
if (!validationResult.isValid) {
OpLog.warn(
'OperationLogMigrationService: Legacy data validation failed, attempting repair',
);
if (!isDataRepairPossible(legacyData as any)) {
throw new Error('Legacy data is corrupted and cannot be repaired');
}
const errors =
'errors' in validationResult.typiaResult
? validationResult.typiaResult.errors
: [];
dataToMigrate = dataRepair(legacyData as any, errors);
// Re-validate after repair to ensure success
const postRepairValidation = validateFull(dataToMigrate);
if (!postRepairValidation.isValid) {
throw new Error('Data repair failed - data still invalid after repair attempt');
}
OpLog.normal('OperationLogMigrationService: Data repair successful');
}
// 3. Get client ID (inherit from legacy or generate new)
const meta = await this.legacyPfDb.loadMetaModel();
const legacyClientId = await this.legacyPfDb.loadClientId();
const clientId = legacyClientId || (await this.clientIdService.generateNewClientId());
OpLog.normal(`OperationLogMigrationService: Using client ID: ${clientId}`);
// 4. Create MIGRATION genesis operation
const migrationOp: Operation = {
id: uuidv7(),
actionType: ActionType.MIGRATION_GENESIS_IMPORT,
opType: OpType.Batch,
entityType: 'MIGRATION',
entityId: '*',
payload: legacyState,
clientId: clientId,
vectorClock: { [clientId]: 1 },
payload: dataToMigrate,
clientId,
vectorClock: meta.vectorClock || { [clientId]: 1 },
timestamp: Date.now(),
schemaVersion: CURRENT_SCHEMA_VERSION,
};
// Write Genesis Operation
await this.opLogStore.append(genesisOp);
// 5. Persist to operation log
await this.opLogStore.append(migrationOp);
const lastSeq = await this.opLogStore.getLastSeq();
// Create initial state cache
await this.opLogStore.saveStateCache({
state: legacyState,
lastAppliedOpSeq: 1, // The genesis op will be seq 1
vectorClock: genesisOp.vectorClock,
state: dataToMigrate,
lastAppliedOpSeq: lastSeq,
vectorClock: migrationOp.vectorClock,
compactedAt: Date.now(),
schemaVersion: CURRENT_SCHEMA_VERSION,
});
// Persist vector clock to IndexedDB store for immediate availability
// Without this, getVectorClock() returns stale clock until cache is populated
await this.opLogStore.setVectorClock(genesisOp.vectorClock);
await this.opLogStore.setVectorClock(migrationOp.vectorClock);
OpLog.normal(
'OperationLogMigrationService: Migration complete. Genesis Operation created.',
);
// 6. Dispatch to NgRx store
this.store.dispatch(loadAllData({ appDataComplete: dataToMigrate }));
this._setStatus(dialogRef, 'complete');
OpLog.normal('OperationLogMigrationService: Migration complete');
// Brief delay to show completion status
await new Promise((resolve) => setTimeout(resolve, 1000));
}
/**
* Shows a dialog informing the user about the system update and downloads a backup.
*/
private async _showMigrationDialogAndCreateBackup(legacyState: unknown): Promise<void> {
// Show informational dialog (Continue button only, no cancel)
await firstValueFrom(
this.matDialog
.open(DialogConfirmComponent, {
disableClose: true,
data: {
title: T.PRE_MIGRATION.DIALOG_TITLE,
message: T.PRE_MIGRATION.DIALOG_MESSAGE,
okTxt: T.G.CONTINUE,
hideCancelButton: true,
},
})
.afterClosed(),
);
// Download backup file
const filename = `super-productivity-pre-migration-backup-${Date.now()}.json`;
await download(filename, JSON.stringify(legacyState));
OpLog.normal('OperationLogMigrationService: Pre-migration backup downloaded.');
private _setStatus(
dialogRef: MatDialogRef<DialogLegacyMigrationComponent>,
status: MigrationStatus,
): void {
dialogRef.componentInstance.status.set(status);
}
}

View file

@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { OperationLogRecoveryService } from './operation-log-recovery.service';
import { OperationLogStoreService } from './operation-log-store.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
import { ClientIdService } from '../../core/util/client-id.service';
import { ActionType, OpType } from '../core/operation.types';
import { PENDING_OPERATION_EXPIRY_MS } from '../core/operation-log.const';
@ -11,12 +11,7 @@ describe('OperationLogRecoveryService', () => {
let service: OperationLogRecoveryService;
let mockStore: jasmine.SpyObj<Store>;
let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
let mockPfapiService: {
pf: {
getAllSyncModelDataFromModelCtrls: jasmine.Spy;
metaModel: { syncVectorClock: jasmine.Spy };
};
};
let mockLegacyPfDb: jasmine.SpyObj<LegacyPfDbService>;
let mockClientIdService: jasmine.SpyObj<ClientIdService>;
beforeEach(() => {
@ -32,12 +27,10 @@ describe('OperationLogRecoveryService', () => {
'getUnsynced',
]);
mockOpLogStore.setVectorClock.and.resolveTo(undefined);
mockPfapiService = {
pf: {
getAllSyncModelDataFromModelCtrls: jasmine.createSpy().and.resolveTo({}),
metaModel: { syncVectorClock: jasmine.createSpy().and.resolveTo(undefined) },
},
};
mockLegacyPfDb = jasmine.createSpyObj('LegacyPfDbService', [
'hasUsableEntityData',
'loadAllEntityData',
]);
mockClientIdService = jasmine.createSpyObj('ClientIdService', ['loadClientId']);
TestBed.configureTestingModule({
@ -45,63 +38,18 @@ describe('OperationLogRecoveryService', () => {
OperationLogRecoveryService,
{ provide: Store, useValue: mockStore },
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
{ provide: PfapiService, useValue: mockPfapiService },
{ provide: LegacyPfDbService, useValue: mockLegacyPfDb },
{ provide: ClientIdService, useValue: mockClientIdService },
],
});
service = TestBed.inject(OperationLogRecoveryService);
});
describe('hasUsableData', () => {
it('should return true when tasks exist', () => {
const data = { task: { ids: ['task1'] } };
expect(service.hasUsableData(data)).toBe(true);
});
it('should return false when task ids are empty', () => {
const data = { task: { ids: [] } };
expect(service.hasUsableData(data)).toBe(false);
});
it('should return true when more than one project exists', () => {
const data = { task: { ids: [] }, project: { ids: ['proj1', 'proj2'] } };
expect(service.hasUsableData(data)).toBe(true);
});
it('should return false when only default project exists', () => {
const data = { task: { ids: [] }, project: { ids: ['defaultProject'] } };
expect(service.hasUsableData(data)).toBe(false);
});
it('should return true when globalConfig has entries', () => {
const data = {
task: { ids: [] },
project: { ids: [] },
globalConfig: { lang: 'en' },
};
expect(service.hasUsableData(data)).toBe(true);
});
it('should return false for completely empty data', () => {
const data = {};
expect(service.hasUsableData(data)).toBe(false);
});
it('should return false when task state is undefined', () => {
const data = { project: { ids: [] } };
expect(service.hasUsableData(data)).toBe(false);
});
it('should return false for empty globalConfig', () => {
const data = { task: { ids: [] }, project: { ids: [] }, globalConfig: {} };
expect(service.hasUsableData(data)).toBe(false);
});
});
describe('attemptRecovery', () => {
it('should recover from legacy data when available', async () => {
const legacyData = { task: { ids: ['task1'] } };
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo(legacyData);
mockLegacyPfDb.hasUsableEntityData.and.resolveTo(true);
mockLegacyPfDb.loadAllEntityData.and.resolveTo(legacyData as any);
mockClientIdService.loadClientId.and.resolveTo('testClient');
mockOpLogStore.append.and.resolveTo(undefined);
mockOpLogStore.getLastSeq.and.resolveTo(1);
@ -121,20 +69,17 @@ describe('OperationLogRecoveryService', () => {
});
it('should not recover when no usable legacy data exists', async () => {
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo({
task: { ids: [] },
});
mockLegacyPfDb.hasUsableEntityData.and.resolveTo(false);
await service.attemptRecovery();
expect(mockLegacyPfDb.loadAllEntityData).not.toHaveBeenCalled();
expect(mockOpLogStore.append).not.toHaveBeenCalled();
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
it('should handle database access errors gracefully', async () => {
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.rejectWith(
new Error('Database error'),
);
mockLegacyPfDb.hasUsableEntityData.and.rejectWith(new Error('Database error'));
// Should not throw
await expectAsync(service.attemptRecovery()).toBeResolved();
@ -193,20 +138,6 @@ describe('OperationLogRecoveryService', () => {
);
});
it('should sync PFAPI vector clock after recovery', async () => {
const legacyData = { task: { ids: ['task1'] } };
mockClientIdService.loadClientId.and.resolveTo('testClient');
mockOpLogStore.append.and.resolveTo(undefined);
mockOpLogStore.getLastSeq.and.resolveTo(1);
mockOpLogStore.saveStateCache.and.resolveTo(undefined);
await service.recoverFromLegacyData(legacyData);
expect(mockPfapiService.pf.metaModel.syncVectorClock).toHaveBeenCalledWith({
testClient: 1,
});
});
it('should persist vector clock to IndexedDB store after recovery', async () => {
const legacyData = { task: { ids: ['task1'] } };
mockClientIdService.loadClientId.and.resolveTo('testClient');

View file

@ -2,14 +2,14 @@ import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { OperationLogStoreService } from './operation-log-store.service';
import { CURRENT_SCHEMA_VERSION } from './schema-migration.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { LegacyPfDbService } from '../../core/persistence/legacy-pf-db.service';
import { ClientIdService } from '../../core/util/client-id.service';
import { loadAllData } from '../../root-store/meta/load-all-data.action';
import { Operation, OpType, ActionType } from '../core/operation.types';
import { uuidv7 } from '../../util/uuid-v7';
import { PENDING_OPERATION_EXPIRY_MS } from '../core/operation-log.const';
import { OpLog } from '../../core/log';
import { AppDataCompleteNew } from '../../pfapi/pfapi-config';
import { AppDataComplete } from '../../sync/model-config';
/**
* Handles crash recovery and data restoration for the operation log system.
@ -26,13 +26,13 @@ import { AppDataCompleteNew } from '../../pfapi/pfapi-config';
export class OperationLogRecoveryService {
private store = inject(Store);
private opLogStore = inject(OperationLogStoreService);
private pfapiService = inject(PfapiService);
private legacyPfDb = inject(LegacyPfDbService);
private clientIdService = inject(ClientIdService);
/**
* Attempts to recover from a corrupted or missing SUP_OPS database.
* Recovery strategy:
* 1. Try to load data from legacy 'pf' database (ModelCtrl caches)
* 1. Try to load data from legacy 'pf' database (IndexedDB)
* 2. If found, run genesis migration with that data
* 3. If no legacy data, log error (user will need to sync or restore from backup)
*/
@ -41,16 +41,16 @@ export class OperationLogRecoveryService {
try {
// 1. Try to load from legacy 'pf' database
const legacyData = await this.pfapiService.pf.getAllSyncModelDataFromModelCtrls();
const hasLegacyData = await this.legacyPfDb.hasUsableEntityData();
// Check if legacy data has any actual content
const hasData = this.hasUsableData(legacyData);
if (hasData) {
if (hasLegacyData) {
OpLog.normal(
'OperationLogRecoveryService: Found data in legacy database. Recovering...',
);
await this.recoverFromLegacyData(legacyData);
const legacyData = await this.legacyPfDb.loadAllEntityData();
await this.recoverFromLegacyData(
legacyData as unknown as Record<string, unknown>,
);
return;
}
@ -69,32 +69,6 @@ export class OperationLogRecoveryService {
}
}
/**
* Checks if the data has any usable content (not just empty/default state).
*/
hasUsableData(data: Record<string, unknown>): boolean {
// Check if there are any tasks (the most important data)
const taskState = data['task'] as { ids?: string[] } | undefined;
if (taskState?.ids && taskState.ids.length > 0) {
return true;
}
// Check if there are any projects beyond the default
const projectState = data['project'] as { ids?: string[] } | undefined;
if (projectState?.ids && projectState.ids.length > 1) {
return true;
}
// Check if there's any configuration that suggests user has used the app
const globalConfig = data['globalConfig'] as Record<string, unknown> | undefined;
if (globalConfig && Object.keys(globalConfig).length > 0) {
// Has some configuration - might be worth recovering
return true;
}
return false;
}
/**
* Recovers from legacy data by creating a new genesis snapshot.
*/
@ -135,13 +109,7 @@ export class OperationLogRecoveryService {
await this.opLogStore.setVectorClock(recoveryOp.vectorClock);
// Dispatch to NgRx
this.store.dispatch(
loadAllData({ appDataComplete: legacyData as AppDataCompleteNew }),
);
// Sync PFAPI vector clock to match the recovery operation
// This ensures that the meta model knows about the new clock state
await this.pfapiService.pf.metaModel.syncVectorClock(recoveryOp.vectorClock);
this.store.dispatch(loadAllData({ appDataComplete: legacyData as AppDataComplete }));
OpLog.normal(
'OperationLogRecoveryService: Recovery complete. Data restored from legacy database.',

View file

@ -7,13 +7,13 @@ import {
SchemaMigrationService,
} from './schema-migration.service';
import { VectorClockService } from '../sync/vector-clock.service';
import { PfapiStoreDelegateService } from '../../pfapi/pfapi-store-delegate.service';
import { StateSnapshotService } from '../../sync/state-snapshot.service';
describe('OperationLogSnapshotService', () => {
let service: OperationLogSnapshotService;
let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
let mockVectorClockService: jasmine.SpyObj<VectorClockService>;
let mockStoreDelegateService: jasmine.SpyObj<PfapiStoreDelegateService>;
let mockStateSnapshotService: jasmine.SpyObj<StateSnapshotService>;
let mockSchemaMigrationService: jasmine.SpyObj<SchemaMigrationService>;
beforeEach(() => {
@ -27,8 +27,8 @@ describe('OperationLogSnapshotService', () => {
mockVectorClockService = jasmine.createSpyObj('VectorClockService', [
'getCurrentVectorClock',
]);
mockStoreDelegateService = jasmine.createSpyObj('PfapiStoreDelegateService', [
'getAllSyncModelDataFromStore',
mockStateSnapshotService = jasmine.createSpyObj('StateSnapshotService', [
'getStateSnapshot',
]);
mockSchemaMigrationService = jasmine.createSpyObj('SchemaMigrationService', [
'migrateStateIfNeeded',
@ -39,7 +39,7 @@ describe('OperationLogSnapshotService', () => {
OperationLogSnapshotService,
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
{ provide: VectorClockService, useValue: mockVectorClockService },
{ provide: PfapiStoreDelegateService, useValue: mockStoreDelegateService },
{ provide: StateSnapshotService, useValue: mockStateSnapshotService },
{ provide: SchemaMigrationService, useValue: mockSchemaMigrationService },
],
});
@ -125,9 +125,7 @@ describe('OperationLogSnapshotService', () => {
globalConfig: {},
};
const vectorClock = { client1: 5, client2: 3 };
mockStoreDelegateService.getAllSyncModelDataFromStore.and.resolveTo(
stateData as any,
);
mockStateSnapshotService.getStateSnapshot.and.resolveTo(stateData as any);
mockVectorClockService.getCurrentVectorClock.and.resolveTo(vectorClock);
mockOpLogStore.getLastSeq.and.resolveTo(10);
mockOpLogStore.saveStateCache.and.resolveTo(undefined);
@ -145,7 +143,7 @@ describe('OperationLogSnapshotService', () => {
});
it('should not throw when save fails', async () => {
mockStoreDelegateService.getAllSyncModelDataFromStore.and.resolveTo({} as any);
mockStateSnapshotService.getStateSnapshot.and.resolveTo({} as any);
mockVectorClockService.getCurrentVectorClock.and.resolveTo({});
mockOpLogStore.getLastSeq.and.resolveTo(1);
mockOpLogStore.saveStateCache.and.rejectWith(new Error('Save failed'));
@ -156,7 +154,7 @@ describe('OperationLogSnapshotService', () => {
it('should include compactedAt timestamp', async () => {
const beforeTime = Date.now();
mockStoreDelegateService.getAllSyncModelDataFromStore.and.resolveTo({} as any);
mockStateSnapshotService.getStateSnapshot.and.resolveTo({} as any);
mockVectorClockService.getCurrentVectorClock.and.resolveTo({});
mockOpLogStore.getLastSeq.and.resolveTo(1);
mockOpLogStore.saveStateCache.and.resolveTo(undefined);

View file

@ -6,7 +6,7 @@ import {
SchemaMigrationService,
} from './schema-migration.service';
import { VectorClockService } from '../sync/vector-clock.service';
import { PfapiStoreDelegateService } from '../../pfapi/pfapi-store-delegate.service';
import { StateSnapshotService } from '../../sync/state-snapshot.service';
import { OpLog } from '../../core/log';
type StateCache = MigratableStateCache;
@ -26,7 +26,7 @@ type StateCache = MigratableStateCache;
export class OperationLogSnapshotService {
private opLogStore = inject(OperationLogStoreService);
private vectorClockService = inject(VectorClockService);
private storeDelegateService = inject(PfapiStoreDelegateService);
private stateSnapshotService = inject(StateSnapshotService);
private schemaMigrationService = inject(SchemaMigrationService);
/**
@ -66,7 +66,7 @@ export class OperationLogSnapshotService {
async saveCurrentStateAsSnapshot(): Promise<void> {
try {
// Get current state from NgRx
const currentState = await this.storeDelegateService.getAllSyncModelDataFromStore();
const currentState = this.stateSnapshotService.getStateSnapshot();
// Get current vector clock and last seq
const vectorClock = await this.vectorClockService.getCurrentVectorClock();

View file

@ -13,9 +13,10 @@ import {
isCompactOperation,
} from '../../core/persistence/operation-log/compact/operation-codec.service';
import { CompactOperation } from '../../core/persistence/operation-log/compact/compact-operation.types';
import { ArchiveModel } from '../../features/time-tracking/time-tracking.model';
const DB_NAME = 'SUP_OPS';
const DB_VERSION = 3;
const DB_VERSION = 4;
/**
* Vector clock entry stored in the vector_clock object store.
@ -26,6 +27,16 @@ interface VectorClockEntry {
lastUpdate: number;
}
/**
* Archive entry stored in archive_young or archive_old object stores.
* Contains the archive data and last modification timestamp.
*/
interface ArchiveStoreEntry {
id: 'current';
data: ArchiveModel;
lastModified: number;
}
/**
* Stored operation log entry that can hold either compact or full operation format.
* Used internally for backwards compatibility with existing data.
@ -108,6 +119,22 @@ interface OpLogDB extends DBSchema {
key: string; // 'current'
value: VectorClockEntry;
};
/**
* Stores archiveYoung data (recently archived tasks, < 21 days).
* Migrated from legacy 'pf' database in version 4.
*/
archive_young: {
key: string; // 'current'
value: ArchiveStoreEntry;
};
/**
* Stores archiveOld data (older archived tasks, >= 21 days).
* Migrated from legacy 'pf' database in version 4.
*/
archive_old: {
key: string; // 'current'
value: ArchiveStoreEntry;
};
}
/**
@ -166,6 +193,13 @@ export class OperationLogStoreService {
const opStore = transaction.objectStore('ops');
opStore.createIndex('bySourceAndStatus', ['source', 'applicationStatus']);
}
// Version 4: Add archive stores for archiveYoung and archiveOld
// Consolidates archive data from legacy 'pf' database into SUP_OPS
if (oldVersion < 4) {
db.createObjectStore('archive_young', { keyPath: 'id' });
db.createObjectStore('archive_old', { keyPath: 'id' });
}
},
});
}
@ -818,13 +852,22 @@ export class OperationLogStoreService {
async _clearAllDataForTesting(): Promise<void> {
await this._ensureInit();
const tx = this.db.transaction(
['ops', 'state_cache', 'import_backup', 'vector_clock'],
[
'ops',
'state_cache',
'import_backup',
'vector_clock',
'archive_young',
'archive_old',
],
'readwrite',
);
await tx.objectStore('ops').clear();
await tx.objectStore('state_cache').clear();
await tx.objectStore('import_backup').clear();
await tx.objectStore('vector_clock').clear();
await tx.objectStore('archive_young').clear();
await tx.objectStore('archive_old').clear();
await tx.done;
// Invalidate all caches
this._appliedOpIdsCache = null;
@ -1046,4 +1089,74 @@ export class OperationLogStoreService {
await tx.done;
return seq as number;
}
// ============================================================
// Archive Storage (migrated from legacy 'pf' database)
// ============================================================
/**
* Loads archiveYoung data from SUP_OPS IndexedDB.
* @returns The archive data, or undefined if not found.
*/
async loadArchiveYoung(): Promise<ArchiveModel | undefined> {
await this._ensureInit();
const entry = await this.db.get('archive_young', 'current');
return entry?.data;
}
/**
* Saves archiveYoung data to SUP_OPS IndexedDB.
* @param data The archive data to save.
*/
async saveArchiveYoung(data: ArchiveModel): Promise<void> {
await this._ensureInit();
await this.db.put('archive_young', {
id: 'current',
data,
lastModified: Date.now(),
});
}
/**
* Loads archiveOld data from SUP_OPS IndexedDB.
* @returns The archive data, or undefined if not found.
*/
async loadArchiveOld(): Promise<ArchiveModel | undefined> {
await this._ensureInit();
const entry = await this.db.get('archive_old', 'current');
return entry?.data;
}
/**
* Saves archiveOld data to SUP_OPS IndexedDB.
* @param data The archive data to save.
*/
async saveArchiveOld(data: ArchiveModel): Promise<void> {
await this._ensureInit();
await this.db.put('archive_old', {
id: 'current',
data,
lastModified: Date.now(),
});
}
/**
* Checks if archiveYoung exists in the database.
* Used to determine if migration from legacy 'pf' database is needed.
*/
async hasArchiveYoung(): Promise<boolean> {
await this._ensureInit();
const entry = await this.db.get('archive_young', 'current');
return !!entry;
}
/**
* Checks if archiveOld exists in the database.
* Used to determine if migration from legacy 'pf' database is needed.
*/
async hasArchiveOld(): Promise<boolean> {
await this._ensureInit();
const entry = await this.db.get('archive_old', 'current');
return !!entry;
}
}

View file

@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { SyncHydrationService } from './sync-hydration.service';
import { OperationLogStoreService } from './operation-log-store.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { StateSnapshotService } from '../../sync/state-snapshot.service';
import { ClientIdService } from '../../core/util/client-id.service';
import { VectorClockService } from '../sync/vector-clock.service';
import { ValidateStateService } from '../validation/validate-state.service';
@ -13,12 +13,7 @@ describe('SyncHydrationService', () => {
let service: SyncHydrationService;
let mockStore: jasmine.SpyObj<Store>;
let mockOpLogStore: jasmine.SpyObj<OperationLogStoreService>;
let mockPfapiService: {
pf: {
getAllSyncModelDataFromModelCtrls: jasmine.Spy;
metaModel: { load: jasmine.Spy };
};
};
let mockStateSnapshotService: jasmine.SpyObj<StateSnapshotService>;
let mockClientIdService: jasmine.SpyObj<ClientIdService>;
let mockVectorClockService: jasmine.SpyObj<VectorClockService>;
let mockValidateStateService: jasmine.SpyObj<ValidateStateService>;
@ -30,13 +25,14 @@ describe('SyncHydrationService', () => {
'getLastSeq',
'saveStateCache',
'setVectorClock',
'loadStateCache',
]);
mockPfapiService = {
pf: {
getAllSyncModelDataFromModelCtrls: jasmine.createSpy().and.resolveTo({}),
metaModel: { load: jasmine.createSpy().and.resolveTo(null) },
},
};
mockStateSnapshotService = jasmine.createSpyObj('StateSnapshotService', [
'getAllSyncModelDataFromStoreAsync',
]);
mockStateSnapshotService.getAllSyncModelDataFromStoreAsync.and.resolveTo({} as any);
// Default: state cache has no vector clock (simulates fresh start)
mockOpLogStore.loadStateCache.and.resolveTo(null);
mockClientIdService = jasmine.createSpyObj('ClientIdService', ['loadClientId']);
mockVectorClockService = jasmine.createSpyObj('VectorClockService', [
'getCurrentVectorClock',
@ -50,7 +46,7 @@ describe('SyncHydrationService', () => {
SyncHydrationService,
{ provide: Store, useValue: mockStore },
{ provide: OperationLogStoreService, useValue: mockOpLogStore },
{ provide: PfapiService, useValue: mockPfapiService },
{ provide: StateSnapshotService, useValue: mockStateSnapshotService },
{ provide: ClientIdService, useValue: mockClientIdService },
{ provide: VectorClockService, useValue: mockVectorClockService },
{ provide: ValidateStateService, useValue: mockValidateStateService },
@ -81,7 +77,9 @@ describe('SyncHydrationService', () => {
archiveYoung: { data: 'young' },
archiveOld: { data: 'old' },
};
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo(archiveData);
mockStateSnapshotService.getAllSyncModelDataFromStoreAsync.and.resolveTo(
archiveData as any,
);
await service.hydrateFromRemoteSync(downloadedData);
@ -108,11 +106,13 @@ describe('SyncHydrationService', () => {
);
});
it('should merge local and PFAPI vector clocks', async () => {
it('should merge local and state cache vector clocks', async () => {
const localClock = { localClient: 5 };
const pfapiClock = { remoteClient: 10, otherClient: 3 };
const stateCacheClock = { remoteClient: 10, otherClient: 3 };
mockVectorClockService.getCurrentVectorClock.and.resolveTo(localClock);
mockPfapiService.pf.metaModel.load.and.resolveTo({ vectorClock: pfapiClock });
mockOpLogStore.loadStateCache.and.resolveTo({
vectorClock: stateCacheClock,
} as any);
await service.hydrateFromRemoteSync({});
@ -124,8 +124,8 @@ describe('SyncHydrationService', () => {
expect(vectorClock['otherClient']).toBe(3);
});
it('should handle missing PFAPI meta model gracefully', async () => {
mockPfapiService.pf.metaModel.load.and.resolveTo(null);
it('should handle missing state cache gracefully', async () => {
mockOpLogStore.loadStateCache.and.resolveTo(null);
await service.hydrateFromRemoteSync({});
@ -135,8 +135,8 @@ describe('SyncHydrationService', () => {
expect(vectorClock['localClient']).toBe(6);
});
it('should handle PFAPI meta model with missing vectorClock', async () => {
mockPfapiService.pf.metaModel.load.and.resolveTo({ someOtherProp: 'value' });
it('should handle state cache with missing vectorClock', async () => {
mockOpLogStore.loadStateCache.and.resolveTo({ someOtherProp: 'value' } as any);
await service.hydrateFromRemoteSync({});
@ -211,7 +211,7 @@ describe('SyncHydrationService', () => {
it('should update vector clock store after sync', async () => {
mockVectorClockService.getCurrentVectorClock.and.resolveTo({ localClient: 5 });
mockPfapiService.pf.metaModel.load.and.resolveTo({ vectorClock: { remote: 3 } });
mockOpLogStore.loadStateCache.and.resolveTo({ vectorClock: { remote: 3 } } as any);
await service.hydrateFromRemoteSync({});
@ -225,7 +225,7 @@ describe('SyncHydrationService', () => {
it('should dispatch loadAllData with synced data', async () => {
const downloadedData = { task: { ids: ['t1'] }, project: {} };
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo({});
mockStateSnapshotService.getAllSyncModelDataFromStoreAsync.and.resolveTo({} as any);
await service.hydrateFromRemoteSync(downloadedData);
@ -274,7 +274,9 @@ describe('SyncHydrationService', () => {
it('should handle null downloadedMainModelData by using only DB data', async () => {
const dbData = { archiveYoung: { data: 'archive' } };
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo(dbData);
mockStateSnapshotService.getAllSyncModelDataFromStoreAsync.and.resolveTo(
dbData as any,
);
await service.hydrateFromRemoteSync(undefined);
@ -305,7 +307,9 @@ describe('SyncHydrationService', () => {
it('should handle non-object data gracefully', async () => {
// Pass null - the merged data should still work
mockPfapiService.pf.getAllSyncModelDataFromModelCtrls.and.resolveTo(null as any);
mockStateSnapshotService.getAllSyncModelDataFromStoreAsync.and.resolveTo(
null as any,
);
// Should not throw when calling hydrateFromRemoteSync with data that gets
// merged with null from DB

View file

@ -2,7 +2,7 @@ import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { OperationLogStoreService } from './operation-log-store.service';
import { CURRENT_SCHEMA_VERSION } from './schema-migration.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { StateSnapshotService } from '../../sync/state-snapshot.service';
import { ClientIdService } from '../../core/util/client-id.service';
import { VectorClockService } from '../sync/vector-clock.service';
import { ValidateStateService } from '../validation/validate-state.service';
@ -11,7 +11,7 @@ import { Operation, OpType, ActionType } from '../core/operation.types';
import { uuidv7 } from '../../util/uuid-v7';
import { incrementVectorClock, mergeVectorClocks } from '../../core/util/vector-clock';
import { OpLog } from '../../core/log';
import { AppDataCompleteNew } from '../../pfapi/pfapi-config';
import { AppDataComplete } from '../../sync/model-config';
/**
* Handles hydration after remote sync downloads.
@ -29,7 +29,7 @@ import { AppDataCompleteNew } from '../../pfapi/pfapi-config';
export class SyncHydrationService {
private store = inject(Store);
private opLogStore = inject(OperationLogStoreService);
private pfapiService = inject(PfapiService);
private stateSnapshotService = inject(StateSnapshotService);
private clientIdService = inject(ClientIdService);
private vectorClockService = inject(VectorClockService);
private validateStateService = inject(ValidateStateService);
@ -57,7 +57,7 @@ export class SyncHydrationService {
// 1. Read archive data from IndexedDB and merge with passed entity data
// Entity models (task, tag, project, etc.) come from downloadedMainModelData
// Archive models (archiveYoung, archiveOld) come from IndexedDB
const dbData = await this.pfapiService.pf.getAllSyncModelDataFromModelCtrls();
const dbData = await this.stateSnapshotService.getAllSyncModelDataFromStoreAsync();
const mergedData = downloadedMainModelData
? { ...dbData, ...downloadedMainModelData }
: dbData;
@ -66,7 +66,7 @@ export class SyncHydrationService {
'SyncHydrationService: Loaded synced data',
downloadedMainModelData
? '(merged passed entity models with archive data from DB)'
: '(from pf database only)',
: '(from state snapshot)',
);
// 2. Get client ID for vector clock
@ -79,16 +79,16 @@ export class SyncHydrationService {
// CRITICAL: The SYNC_IMPORT's clock must include ALL known clients, not just local ones.
// If we only use the local clock, ops from other clients will be CONCURRENT with
// this import and get filtered out by SyncImportFilterService.
// By merging the PFAPI meta model's clock (which includes synced clients),
// we ensure ops created AFTER this sync point are GREATER_THAN the import.
// We now use the state cache's vector clock (which includes synced clients),
// to ensure ops created AFTER this sync point are GREATER_THAN the import.
const localClock = await this.vectorClockService.getCurrentVectorClock();
const pfapiMetaModel = await this.pfapiService.pf.metaModel.load();
const pfapiClock = pfapiMetaModel?.vectorClock || {};
const mergedClock = mergeVectorClocks(localClock, pfapiClock);
const stateCache = await this.opLogStore.loadStateCache();
const stateCacheClock = stateCache?.vectorClock || {};
const mergedClock = mergeVectorClocks(localClock, stateCacheClock);
const newClock = incrementVectorClock(mergedClock, clientId);
OpLog.normal('SyncHydrationService: Creating SYNC_IMPORT with merged clock', {
localClockSize: Object.keys(localClock).length,
pfapiClockSize: Object.keys(pfapiClock).length,
stateCacheClockSize: Object.keys(stateCacheClock).length,
mergedClockSize: Object.keys(mergedClock).length,
});
@ -113,10 +113,11 @@ export class SyncHydrationService {
// 6. Validate and repair synced data before dispatching
// This fixes stale task references (e.g., tags/projects referencing deleted tasks)
let dataToLoad = syncedData as AppDataCompleteNew;
let dataToLoad = syncedData as AppDataComplete;
const validationResult = this.validateStateService.validateAndRepair(dataToLoad);
if (validationResult.wasRepaired && validationResult.repairedState) {
dataToLoad = validationResult.repairedState;
// Cast to any since Record<string, unknown> doesn't directly map to AppDataComplete
dataToLoad = validationResult.repairedState as any;
OpLog.normal('SyncHydrationService: Repaired synced data before loading');
}

View file

@ -1,14 +1,14 @@
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ImmediateUploadService } from './immediate-upload.service';
import { PfapiService } from '../../pfapi/pfapi.service';
import { SyncProviderManager } from '../../sync/provider-manager.service';
import { OperationLogSyncService } from './operation-log-sync.service';
import { ActionType, Operation, OpType } from '../core/operation.types';
describe('ImmediateUploadService', () => {
let service: ImmediateUploadService;
let mockPfapiService: any;
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
let mockSyncService: jasmine.SpyObj<OperationLogSyncService>;
let syncStatusEmitSpy: jasmine.Spy;
let mockProvider: any;
const createMockOp = (id: string): Operation => ({
id,
@ -24,24 +24,22 @@ describe('ImmediateUploadService', () => {
});
beforeEach(() => {
syncStatusEmitSpy = jasmine.createSpy('emit');
mockPfapiService = {
pf: {
isSyncInProgress: false,
getActiveSyncProvider: jasmine
.createSpy('getActiveSyncProvider')
.and.returnValue({
id: 'SuperProductivitySync',
supportsOperationSync: true, // Required for isOperationSyncCapable check
uploadOperations: jasmine.createSpy('uploadOperations'),
isReady: jasmine.createSpy('isReady').and.returnValue(Promise.resolve(true)),
}),
ev: {
emit: syncStatusEmitSpy,
},
},
mockProvider = {
id: 'SuperProductivitySync',
supportsOperationSync: true, // Required for isOperationSyncCapable check
uploadOperations: jasmine.createSpy('uploadOperations'),
isReady: jasmine.createSpy('isReady').and.returnValue(Promise.resolve(true)),
};
mockProviderManager = jasmine.createSpyObj(
'SyncProviderManager',
['getActiveProvider', 'setSyncStatus'],
{
isSyncInProgress: false,
},
);
mockProviderManager.getActiveProvider.and.returnValue(mockProvider);
// ImmediateUploadService now calls syncService.uploadPendingOps() which includes:
// - Server migration detection
// - Processing of piggybacked ops
@ -53,7 +51,7 @@ describe('ImmediateUploadService', () => {
TestBed.configureTestingModule({
providers: [
ImmediateUploadService,
{ provide: PfapiService, useValue: mockPfapiService },
{ provide: SyncProviderManager, useValue: mockProviderManager },
{ provide: OperationLogSyncService, useValue: mockSyncService },
],
});
@ -80,7 +78,7 @@ describe('ImmediateUploadService', () => {
service.trigger();
tick(2100); // Debounce (2000ms) + processing
expect(syncStatusEmitSpy).toHaveBeenCalledWith('syncStatusChange', 'IN_SYNC');
expect(mockProviderManager.setSyncStatus).toHaveBeenCalledWith('IN_SYNC');
}));
it('should NOT show checkmark when piggybacked ops exist', fakeAsync(() => {
@ -100,7 +98,7 @@ describe('ImmediateUploadService', () => {
// Piggybacked ops are processed internally by syncService.uploadPendingOps()
// ImmediateUploadService should NOT show checkmark when there are piggybacked ops
expect(syncStatusEmitSpy).not.toHaveBeenCalled();
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalled();
}));
it('should NOT show checkmark when nothing was uploaded', fakeAsync(() => {
@ -117,7 +115,7 @@ describe('ImmediateUploadService', () => {
service.trigger();
tick(2100);
expect(syncStatusEmitSpy).not.toHaveBeenCalled();
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalled();
}));
it('should NOT show checkmark when upload fails', async () => {
@ -132,7 +130,7 @@ describe('ImmediateUploadService', () => {
await new Promise((resolve) => setTimeout(resolve, 150));
// Silent failure - no checkmark, no error state
expect(syncStatusEmitSpy).not.toHaveBeenCalled();
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalled();
});
it('should NOT show checkmark when piggybacked ops exist (multiple)', fakeAsync(() => {
@ -155,13 +153,14 @@ describe('ImmediateUploadService', () => {
tick(2100);
// Piggybacked ops are processed internally, no checkmark shown
expect(syncStatusEmitSpy).not.toHaveBeenCalled();
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalled();
}));
});
describe('guards', () => {
it('should skip upload when sync is in progress', fakeAsync(() => {
mockPfapiService.pf.isSyncInProgress = true;
// Need to re-create the mock with isSyncInProgress = true
Object.defineProperty(mockProviderManager, 'isSyncInProgress', { value: true });
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
uploadedCount: 1,
@ -189,13 +188,11 @@ describe('ImmediateUploadService', () => {
// Upload was called, but returned null - no checkmark shown
expect(mockSyncService.uploadPendingOps).toHaveBeenCalled();
expect(syncStatusEmitSpy).not.toHaveBeenCalled();
expect(mockProviderManager.setSyncStatus).not.toHaveBeenCalled();
}));
it('should skip upload when provider is not ready', fakeAsync(() => {
mockPfapiService.pf
.getActiveSyncProvider()
.isReady.and.returnValue(Promise.resolve(false));
mockProvider.isReady.and.returnValue(Promise.resolve(false));
mockSyncService.uploadPendingOps.and.returnValue(
Promise.resolve({
uploadedCount: 1,

View file

@ -2,7 +2,7 @@ import { inject, Injectable, OnDestroy } from '@angular/core';
import { Subject, Subscription } from 'rxjs';
import { debounceTime, exhaustMap, filter } from 'rxjs/operators';
import { isOnline } from '../../util/is-online';
import { PfapiService } from '../../pfapi/pfapi.service';
import { SyncProviderManager } from '../../sync/provider-manager.service';
import { OperationLogSyncService } from './operation-log-sync.service';
import { isOperationSyncCapable } from './operation-sync.util';
import { OpLog } from '../../core/log';
@ -39,7 +39,7 @@ const IMMEDIATE_UPLOAD_DEBOUNCE_MS = 2000;
providedIn: 'root',
})
export class ImmediateUploadService implements OnDestroy {
private _pfapiService = inject(PfapiService);
private _providerManager = inject(SyncProviderManager);
private _syncService = inject(OperationLogSyncService);
private _uploadTrigger$ = new Subject<void>();
@ -87,12 +87,12 @@ export class ImmediateUploadService implements OnDestroy {
}
// Don't overlap with full sync
if (this._pfapiService.pf.isSyncInProgress) {
if (this._providerManager.isSyncInProgress) {
return false;
}
// Must have SuperSync active (operation-sync capable)
const provider = this._pfapiService.pf.getActiveSyncProvider();
const provider = this._providerManager.getActiveProvider();
if (!provider || !isOperationSyncCapable(provider)) {
return false;
}
@ -109,7 +109,7 @@ export class ImmediateUploadService implements OnDestroy {
* - Handling of rejected ops
*/
private async _performUpload(): Promise<void> {
const provider = this._pfapiService.pf.getActiveSyncProvider();
const provider = this._providerManager.getActiveProvider();
if (!provider || !isOperationSyncCapable(provider)) {
return;
}
@ -157,7 +157,7 @@ export class ImmediateUploadService implements OnDestroy {
// Show checkmark ONLY when server confirms no pending remote ops
// (empty piggybackedOps means we're confirmed in sync)
if (result.uploadedCount > 0 || (result.localWinOpsCreated ?? 0) > 0) {
this._pfapiService.pf.ev.emit('syncStatusChange', 'IN_SYNC');
this._providerManager.setSyncStatus('IN_SYNC');
OpLog.verbose(
`ImmediateUploadService: Uploaded ${result.uploadedCount} ops, confirmed in sync`,
);

Some files were not shown because too many files have changed in this diff Show more