mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
refactor: integrate pfapi into oplog 2
This commit is contained in:
parent
1f8fe61c84
commit
db990b7018
232 changed files with 4941 additions and 9613 deletions
144
docs/plans/pfapi-elimination-status.md
Normal file
144
docs/plans/pfapi-elimination-status.md
Normal 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
|
||||
```
|
||||
4
electron/electronAPI.d.ts
vendored
4
electron/electronAPI.d.ts
vendored
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
411
src/app/core/persistence/legacy-pf-db.service.spec.ts
Normal file
411
src/app/core/persistence/legacy-pf-db.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
370
src/app/core/persistence/legacy-pf-db.service.ts
Normal file
370
src/app/core/persistence/legacy-pf-db.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
15
src/app/features/archive/archive.model.ts
Normal file
15
src/app/features/archive/archive.model.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
// }
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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: {} },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ----------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
90
src/app/op-log/store/archive-migration.service.ts
Normal file
90
src/app/op-log/store/archive-migration.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue