diff --git a/src/app/core/persistence/operation-log/sync/operation-log-sync.service.spec.ts b/src/app/core/persistence/operation-log/sync/operation-log-sync.service.spec.ts index cc586ac5e..69159af2b 100644 --- a/src/app/core/persistence/operation-log/sync/operation-log-sync.service.spec.ts +++ b/src/app/core/persistence/operation-log/sync/operation-log-sync.service.spec.ts @@ -19,7 +19,7 @@ import { DependencyResolverService } from './dependency-resolver.service'; import { LockService } from './lock.service'; import { OperationLogCompactionService } from '../store/operation-log-compaction.service'; import { SyncImportFilterService } from './sync-import-filter.service'; -import { Store } from '@ngrx/store'; +import { ServerMigrationService } from './server-migration.service'; import { provideMockStore } from '@ngrx/store/testing'; import { Operation, OpType } from '../operation.types'; import { T } from '../../../../t.const'; @@ -46,6 +46,7 @@ describe('OperationLogSyncService', () => { let lockServiceSpy: jasmine.SpyObj; let compactionServiceSpy: jasmine.SpyObj; let syncImportFilterServiceSpy: jasmine.SpyObj; + let serverMigrationServiceSpy: jasmine.SpyObj; beforeEach(() => { schemaMigrationServiceSpy = jasmine.createSpyObj('SchemaMigrationService', [ @@ -107,6 +108,13 @@ describe('OperationLogSyncService', () => { syncImportFilterServiceSpy.filterOpsInvalidatedBySyncImport.and.callFake( (ops: any[]) => Promise.resolve({ validOps: ops, invalidatedOps: [] }), ); + serverMigrationServiceSpy = jasmine.createSpyObj('ServerMigrationService', [ + 'checkAndHandleMigration', + 'handleServerMigration', + ]); + // Default: server migration check does nothing + serverMigrationServiceSpy.checkAndHandleMigration.and.resolveTo(); + serverMigrationServiceSpy.handleServerMigration.and.resolveTo(); TestBed.configureTestingModule({ providers: [ @@ -163,6 +171,7 @@ describe('OperationLogSyncService', () => { useValue: jasmine.createSpyObj('TranslateService', ['instant']), }, { provide: SyncImportFilterService, useValue: syncImportFilterServiceSpy }, + { provide: ServerMigrationService, useValue: serverMigrationServiceSpy }, ], }); @@ -3099,8 +3108,7 @@ describe('OperationLogSyncService', () => { }), ); - // Mock the server migration handler - spyOn(service, '_handleServerMigration').and.returnValue(Promise.resolve()); + // serverMigrationServiceSpy.handleServerMigration is already mocked in beforeEach const mockProvider = { isReady: () => Promise.resolve(true), @@ -3252,614 +3260,6 @@ describe('OperationLogSyncService', () => { }); }); - describe('_handleServerMigration state validation', () => { - let storeDelegateSpy: jasmine.SpyObj; - let mockStore: jasmine.SpyObj; - - beforeEach(() => { - storeDelegateSpy = TestBed.inject( - PfapiStoreDelegateService, - ) as jasmine.SpyObj; - mockStore = TestBed.inject(Store) as jasmine.SpyObj; - spyOn(mockStore, 'dispatch'); - - // Setup default mocks for client ID and vector clock - vectorClockServiceSpy.getCurrentVectorClock = jasmine - .createSpy('getCurrentVectorClock') - .and.returnValue(Promise.resolve({ testClientId: 1 })); - }); - - it('should validate state before creating SYNC_IMPORT', async () => { - // Setup: valid state with some tasks (cast to any to avoid typing all properties) - const mockState = { - task: { - ids: ['task1'], - entities: { task1: { id: 'task1', title: 'Test' } }, - }, - project: { ids: [], entities: {} }, - tag: { ids: [], entities: {} }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(mockState), - ); - - // Validation returns valid (no repair needed) - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: true, - wasRepaired: false, - } as any); - - // Call the private method - await (service as any)._handleServerMigration(); - - // Should have called validateAndRepair - expect(validateStateServiceSpy.validateAndRepair).toHaveBeenCalledWith(mockState); - }); - - it('should repair state before creating SYNC_IMPORT when validation finds issues', async () => { - // Setup: corrupted state (cast to any to avoid typing all properties) - const corruptedState = { - task: { - ids: ['task1'], - entities: { task1: { id: 'task1', title: 'Test' } }, - }, - project: { ids: [], entities: {} }, - tag: { ids: [], entities: {} }, - menuTree: { - projectTree: [{ k: 'PROJECT', id: 'deleted-project' }], // orphaned reference - tagTree: [], - }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(corruptedState), - ); - - // Repaired state (menuTree cleaned up) - const repairedState = { - ...corruptedState, - menuTree: { - projectTree: [], // orphaned reference removed - tagTree: [], - }, - } as any; - - // Validation returns repaired state - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: true, - wasRepaired: true, - repairedState, - repairSummary: { menuTreeProjectsRemoved: 1 }, - } as any); - - // Call the private method - await (service as any)._handleServerMigration(); - - // Should have called validateAndRepair - expect(validateStateServiceSpy.validateAndRepair).toHaveBeenCalledWith( - corruptedState, - ); - - // Should have dispatched loadAllData with repaired state - expect(mockStore.dispatch).toHaveBeenCalled(); - const dispatchedAction = mockStore.dispatch.calls.mostRecent().args[0]; - expect(dispatchedAction.type).toBe('[SP_ALL] Load(import) all data'); - expect(dispatchedAction.appDataComplete).toEqual(repairedState); - - // Should have appended operation with repaired state (not corrupted state) - expect(opLogStoreSpy.append).toHaveBeenCalled(); - const appendedOp = opLogStoreSpy.append.calls.mostRecent().args[0]; - expect(appendedOp.payload).toEqual(repairedState); - }); - - it('should use original state if validation finds no issues', async () => { - const validState = { - task: { - ids: ['task1'], - entities: { task1: { id: 'task1', title: 'Test' } }, - }, - project: { ids: [], entities: {} }, - tag: { ids: [], entities: {} }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(validState), - ); - - // Validation returns valid (no repair) - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: true, - wasRepaired: false, - } as any); - - await (service as any)._handleServerMigration(); - - // Should NOT dispatch loadAllData (no repair needed) - expect(mockStore.dispatch).not.toHaveBeenCalled(); - - // Should have appended operation with original state - expect(opLogStoreSpy.append).toHaveBeenCalled(); - const appendedOp = opLogStoreSpy.append.calls.mostRecent().args[0]; - expect(appendedOp.payload).toEqual(validState); - }); - - it('should skip SYNC_IMPORT creation for empty state', async () => { - // Empty state (no tasks, projects, tags) - const emptyState = { - task: { ids: [], entities: {} }, - project: { ids: [], entities: {} }, - tag: { ids: [], entities: {} }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(emptyState), - ); - - await (service as any)._handleServerMigration(); - - // Should NOT call validateAndRepair (early return for empty state) - expect(validateStateServiceSpy.validateAndRepair).not.toHaveBeenCalled(); - - // Should NOT append operation - expect(opLogStoreSpy.append).not.toHaveBeenCalled(); - }); - - it('should abort SYNC_IMPORT and show snackbar when validation fails and repair is not possible', async () => { - // Scenario: state is corrupted and cannot be repaired - const corruptedState = { - task: { - ids: ['task1'], - entities: { task1: { id: 'task1', title: 'Test' } }, - }, - project: { ids: [], entities: {} }, - tag: { ids: [], entities: {} }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(corruptedState), - ); - - // Validation fails, repair not possible - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: false, - wasRepaired: false, - error: 'Data repair not possible - state too corrupted', - } as any); - - await (service as any)._handleServerMigration(); - - // Should NOT create SYNC_IMPORT when validation fails - expect(opLogStoreSpy.append).not.toHaveBeenCalled(); - - // Should NOT dispatch loadAllData - expect(mockStore.dispatch).not.toHaveBeenCalled(); - - // Should show error snackbar to notify user - expect(snackServiceSpy.open).toHaveBeenCalledWith({ - type: 'ERROR', - msg: T.F.SYNC.S.SERVER_MIGRATION_VALIDATION_FAILED, - }); - }); - - it('should abort SYNC_IMPORT and show snackbar when repair runs but fails to fix state', async () => { - // Scenario: repair attempted but state still invalid - const corruptedState = { - task: { - ids: ['task1'], - entities: { task1: { id: 'task1', title: 'Test' } }, - }, - project: { ids: [], entities: {} }, - tag: { ids: [], entities: {} }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(corruptedState), - ); - - // Repair ran but failed to fully fix the state - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: false, - wasRepaired: true, - repairedState: corruptedState, // Partially repaired but still invalid - error: 'State still invalid after repair', - } as any); - - await (service as any)._handleServerMigration(); - - // Should NOT create SYNC_IMPORT when state is still invalid - expect(opLogStoreSpy.append).not.toHaveBeenCalled(); - - // Should NOT dispatch the still-invalid state - expect(mockStore.dispatch).not.toHaveBeenCalled(); - - // Should show error snackbar to notify user - expect(snackServiceSpy.open).toHaveBeenCalledWith({ - type: 'ERROR', - msg: T.F.SYNC.S.SERVER_MIGRATION_VALIDATION_FAILED, - }); - }); - - it('should handle orphaned tag references in menuTree', async () => { - // Scenario: menuTree has tag references that don't exist - const corruptedState = { - task: { - ids: ['task1'], - entities: { task1: { id: 'task1', title: 'Test' } }, - }, - project: { ids: [], entities: {} }, - tag: { ids: ['tag1'], entities: { tag1: { id: 'tag1', title: 'Valid' } } }, - menuTree: { - projectTree: [], - tagTree: [ - { k: 'TAG', id: 'tag1' }, // valid - { k: 'TAG', id: 'deleted-tag' }, // orphaned - ], - }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(corruptedState), - ); - - const repairedState = { - ...corruptedState, - menuTree: { - projectTree: [], - tagTree: [{ k: 'TAG', id: 'tag1' }], // only valid tag - }, - } as any; - - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: true, - wasRepaired: true, - repairedState, - repairSummary: { orphanedTagsRemoved: 1 }, - } as any); - - await (service as any)._handleServerMigration(); - - // Should use repaired state - expect(opLogStoreSpy.append).toHaveBeenCalled(); - const appendedOp = opLogStoreSpy.append.calls.mostRecent().args[0]; - expect((appendedOp.payload as any).menuTree.tagTree).toEqual([ - { k: 'TAG', id: 'tag1' }, - ]); - }); - - it('should handle multiple types of corruption in single repair', async () => { - // Scenario: multiple issues - orphaned project and tag in menuTree - const corruptedState = { - task: { - ids: ['task1'], - entities: { task1: { id: 'task1', title: 'Test', tagIds: ['deleted-tag'] } }, - }, - project: { ids: [], entities: {} }, - tag: { ids: [], entities: {} }, - menuTree: { - projectTree: [{ k: 'PROJECT', id: 'deleted-project' }], - tagTree: [{ k: 'TAG', id: 'deleted-tag' }], - }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(corruptedState), - ); - - const repairedState = { - task: { - ids: ['task1'], - entities: { task1: { id: 'task1', title: 'Test', tagIds: [] } }, // cleaned up - }, - project: { ids: [], entities: {} }, - tag: { ids: [], entities: {} }, - menuTree: { - projectTree: [], // cleaned up - tagTree: [], // cleaned up - }, - } as any; - - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: true, - wasRepaired: true, - repairedState, - repairSummary: { - orphanedProjectsRemoved: 1, - orphanedTagsRemoved: 1, - taskTagIdsFixed: 1, - }, - } as any); - - await (service as any)._handleServerMigration(); - - // Should dispatch with fully repaired state - expect(mockStore.dispatch).toHaveBeenCalled(); - - // SYNC_IMPORT should have repaired state - const appendedOp = opLogStoreSpy.append.calls.mostRecent().args[0]; - expect(appendedOp.payload).toEqual(repairedState); - expect((appendedOp.payload as any).menuTree.projectTree).toEqual([]); - expect((appendedOp.payload as any).menuTree.tagTree).toEqual([]); - expect((appendedOp.payload as any).task.entities.task1.tagIds).toEqual([]); - }); - - it('should create SYNC_IMPORT with correct metadata after repair', async () => { - const corruptedState = { - task: { - ids: ['task1'], - entities: { task1: { id: 'task1', title: 'Test' } }, - }, - project: { ids: [], entities: {} }, - tag: { ids: [], entities: {} }, - menuTree: { - projectTree: [{ k: 'PROJECT', id: 'orphan' }], - tagTree: [], - }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(corruptedState), - ); - - const repairedState = { - ...corruptedState, - menuTree: { projectTree: [], tagTree: [] }, - } as any; - - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: true, - wasRepaired: true, - repairedState, - } as any); - - await (service as any)._handleServerMigration(); - - // Verify SYNC_IMPORT operation structure - expect(opLogStoreSpy.append).toHaveBeenCalled(); - const appendedOp = opLogStoreSpy.append.calls.mostRecent().args[0]; - - expect(appendedOp.actionType).toBe('[SP_ALL] Load(import) all data'); - expect(appendedOp.opType).toBe(OpType.SyncImport); - expect(appendedOp.entityType).toBe('ALL'); - expect(appendedOp.clientId).toBe('test-client-id'); - expect(appendedOp.vectorClock).toBeDefined(); - expect(appendedOp.timestamp).toBeDefined(); - expect(appendedOp.schemaVersion).toBeDefined(); - }); - - it('should validate state with project data', async () => { - // State with projects (not empty) - const stateWithProject = { - task: { ids: [], entities: {} }, - project: { - ids: ['proj1'], - entities: { proj1: { id: 'proj1', title: 'My Project' } }, - }, - tag: { ids: [], entities: {} }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(stateWithProject), - ); - - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: true, - wasRepaired: false, - } as any); - - await (service as any)._handleServerMigration(); - - // Should call validateAndRepair (has project data) - expect(validateStateServiceSpy.validateAndRepair).toHaveBeenCalledWith( - stateWithProject, - ); - expect(opLogStoreSpy.append).toHaveBeenCalled(); - }); - - it('should validate state with tag data', async () => { - // State with tags (not empty) - const stateWithTag = { - task: { ids: [], entities: {} }, - project: { ids: [], entities: {} }, - tag: { - ids: ['tag1'], - entities: { tag1: { id: 'tag1', title: 'My Tag' } }, - }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(stateWithTag), - ); - - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: true, - wasRepaired: false, - } as any); - - await (service as any)._handleServerMigration(); - - // Should call validateAndRepair (has tag data) - expect(validateStateServiceSpy.validateAndRepair).toHaveBeenCalledWith( - stateWithTag, - ); - expect(opLogStoreSpy.append).toHaveBeenCalled(); - }); - - it('should skip system tags when checking for empty state', async () => { - // State with ONLY system tag (TODAY tag) - should be considered empty - const stateWithOnlySystemTag = { - task: { ids: [], entities: {} }, - project: { ids: [], entities: {} }, - tag: { - ids: ['TODAY'], - entities: { TODAY: { id: 'TODAY', title: 'Today' } }, - }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(stateWithOnlySystemTag), - ); - - await (service as any)._handleServerMigration(); - - // Should NOT call validateAndRepair (only system tags = empty) - expect(validateStateServiceSpy.validateAndRepair).not.toHaveBeenCalled(); - expect(opLogStoreSpy.append).not.toHaveBeenCalled(); - }); - - it('should proceed with user tag alongside system tag', async () => { - // State with system tag AND user tag - NOT empty - const stateWithUserAndSystemTag = { - task: { ids: [], entities: {} }, - project: { ids: [], entities: {} }, - tag: { - ids: ['TODAY', 'userTag1'], - entities: { - TODAY: { id: 'TODAY', title: 'Today' }, - userTag1: { id: 'userTag1', title: 'User Tag' }, - }, - }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(stateWithUserAndSystemTag), - ); - - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: true, - wasRepaired: false, - } as any); - - await (service as any)._handleServerMigration(); - - // Should call validateAndRepair (has user tag) - expect(validateStateServiceSpy.validateAndRepair).toHaveBeenCalled(); - expect(opLogStoreSpy.append).toHaveBeenCalled(); - }); - - it('should call validation exactly once per migration', async () => { - const mockState = { - task: { - ids: ['task1'], - entities: { task1: { id: 'task1', title: 'Test' } }, - }, - project: { ids: [], entities: {} }, - tag: { ids: [], entities: {} }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(mockState), - ); - - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: true, - wasRepaired: false, - } as any); - - await (service as any)._handleServerMigration(); - - // Should be called exactly once - expect(validateStateServiceSpy.validateAndRepair).toHaveBeenCalledTimes(1); - }); - - it('should handle nested folder structure in menuTree repair', async () => { - // Complex menuTree with nested folders - const corruptedState = { - task: { - ids: ['task1'], - entities: { task1: { id: 'task1', title: 'Test' } }, - }, - project: { - ids: ['proj1'], - entities: { proj1: { id: 'proj1', title: 'Valid Project' } }, - }, - tag: { ids: [], entities: {} }, - menuTree: { - projectTree: [ - { - k: 'FOLDER', - id: 'folder1', - name: 'My Folder', - children: [ - { k: 'PROJECT', id: 'proj1' }, // valid - { k: 'PROJECT', id: 'deleted-project' }, // orphaned - ], - }, - ], - tagTree: [], - }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(corruptedState), - ); - - const repairedState = { - ...corruptedState, - menuTree: { - projectTree: [ - { - k: 'FOLDER', - id: 'folder1', - name: 'My Folder', - children: [{ k: 'PROJECT', id: 'proj1' }], // only valid - }, - ], - tagTree: [], - }, - } as any; - - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: true, - wasRepaired: true, - repairedState, - } as any); - - await (service as any)._handleServerMigration(); - - // Verify nested structure is preserved correctly - const appendedOp = opLogStoreSpy.append.calls.mostRecent().args[0]; - const projectTree = (appendedOp.payload as any).menuTree.projectTree; - expect(projectTree.length).toBe(1); - expect(projectTree[0].k).toBe('FOLDER'); - expect(projectTree[0].children.length).toBe(1); - expect(projectTree[0].children[0].id).toBe('proj1'); - }); - - describe('integration with downloadRemoteOps', () => { - let downloadServiceSpy: jasmine.SpyObj; - - beforeEach(() => { - downloadServiceSpy = TestBed.inject( - OperationLogDownloadService, - ) as jasmine.SpyObj; - }); - - it('should validate state when needsFullStateUpload is true', async () => { - const mockState = { - task: { - ids: ['task1'], - entities: { task1: { id: 'task1', title: 'Test' } }, - }, - project: { ids: [], entities: {} }, - tag: { ids: [], entities: {} }, - } as any; - storeDelegateSpy.getAllSyncModelDataFromStore.and.returnValue( - Promise.resolve(mockState), - ); - - downloadServiceSpy.downloadRemoteOps.and.returnValue( - Promise.resolve({ - newOps: [], - hasMore: false, - needsFullStateUpload: true, // Triggers server migration - success: true, - failedFileCount: 0, - latestServerSeq: 0, - }), - ); - - validateStateServiceSpy.validateAndRepair.and.returnValue({ - isValid: true, - wasRepaired: false, - } as any); - - const mockProvider = { - isReady: () => Promise.resolve(true), - supportsOperationSync: true, - setLastServerSeq: jasmine.createSpy('setLastServerSeq').and.resolveTo(), - } as any; - - await service.downloadRemoteOps(mockProvider); - - // Should have called validateAndRepair as part of server migration - expect(validateStateServiceSpy.validateAndRepair).toHaveBeenCalledWith(mockState); - }); - }); - }); + // NOTE: Old _handleServerMigration state validation tests (600+ lines) have been moved to + // server-migration.service.spec.ts. The OperationLogSyncService now delegates to ServerMigrationService. }); diff --git a/src/app/core/persistence/operation-log/sync/operation-log-sync.service.ts b/src/app/core/persistence/operation-log/sync/operation-log-sync.service.ts index ad429c97b..c63c933e5 100644 --- a/src/app/core/persistence/operation-log/sync/operation-log-sync.service.ts +++ b/src/app/core/persistence/operation-log/sync/operation-log-sync.service.ts @@ -46,11 +46,9 @@ import { lazyInject } from '../../../../util/lazy-inject'; import { MAX_REJECTED_OPS_BEFORE_WARNING } from '../operation-log.const'; import { LockService } from './lock.service'; import { OperationLogCompactionService } from '../store/operation-log-compaction.service'; -import { SYSTEM_TAG_IDS } from '../../../../features/tag/tag.const'; import { SuperSyncStatusService } from './super-sync-status.service'; -import { loadAllData } from '../../../../root-store/meta/load-all-data.action'; -import { AppDataCompleteNew } from '../../../../pfapi/pfapi-config'; import { SyncImportFilterService } from './sync-import-filter.service'; +import { ServerMigrationService } from './server-migration.service'; /** * Orchestrates synchronization of the Operation Log with remote storage. @@ -136,6 +134,7 @@ export class OperationLogSyncService { private compactionService = inject(OperationLogCompactionService); private superSyncStatusService = inject(SuperSyncStatusService); private syncImportFilterService = inject(SyncImportFilterService); + private serverMigrationService = inject(ServerMigrationService); // Lazy injection to break circular dependency: // PfapiService -> Pfapi -> OperationLogSyncService -> PfapiService @@ -196,7 +195,8 @@ export class OperationLogSyncService { // This prevents race conditions where multiple tabs could both detect migration // and create duplicate SYNC_IMPORT operations. const result = await this.uploadService.uploadPendingOps(syncProvider, { - preUploadCallback: () => this._checkAndHandleServerMigration(syncProvider), + preUploadCallback: () => + this.serverMigrationService.checkAndHandleMigration(syncProvider), }); // STEP 1: Process piggybacked ops FIRST @@ -585,7 +585,7 @@ export class OperationLogSyncService { // Server migration detected: gap on empty server // Create a SYNC_IMPORT operation with full local state to seed the new server if (result.needsFullStateUpload) { - await this._handleServerMigration(); + await this.serverMigrationService.handleServerMigration(); // Persist lastServerSeq=0 for the migration case (server was reset) if (isOperationSyncCapable(syncProvider) && result.latestServerSeq !== undefined) { await syncProvider.setLastServerSeq(result.latestServerSeq); @@ -677,199 +677,6 @@ export class OperationLogSyncService { return window.confirm(`${title}\n\n${message}`); } - /** - * Check if we're connecting to a new/empty server and need to upload full state. - * - * This handles the server migration scenario: - * - Client has PREVIOUSLY SYNCED operations (not just local ops) - * - lastServerSeq is 0 for this server (first time connecting) - * - Server is empty (latestSeq = 0) - * - * When detected, creates a SYNC_IMPORT with full state before regular ops are uploaded. - * - * IMPORTANT: A fresh client with only local (unsynced) ops is NOT a migration scenario. - * Fresh clients should just upload their ops normally without creating a SYNC_IMPORT. - */ - private async _checkAndHandleServerMigration( - syncProvider: SyncProviderServiceInterface, - ): Promise { - // Only check for operation-sync capable providers - if (!isOperationSyncCapable(syncProvider)) { - return; - } - - // Check if lastServerSeq is 0 (first time connecting to this server) - const lastServerSeq = await syncProvider.getLastServerSeq(); - if (lastServerSeq !== 0) { - // We've synced with this server before, no migration needed - return; - } - - // Check if server is empty by doing a minimal download request - const response = await syncProvider.downloadOps(0, undefined, 1); - if (response.latestSeq !== 0) { - // Server has data, this is not a migration scenario - // (might be joining an existing sync group) - return; - } - - // CRITICAL: Check if this client has PREVIOUSLY synced operations. - // A client that has never synced (only local ops) is NOT a migration case. - // It's just a fresh client that should upload its ops normally. - const hasSyncedOps = await this.opLogStore.hasSyncedOps(); - if (!hasSyncedOps) { - OpLog.normal( - 'OperationLogSyncService: Empty server detected, but no previously synced ops. ' + - 'This is a fresh client, not a server migration. Proceeding with normal upload.', - ); - return; - } - - // Server is empty AND we have PREVIOUSLY SYNCED ops AND lastServerSeq is 0 - // This is a server migration - create SYNC_IMPORT with full state - OpLog.warn( - 'OperationLogSyncService: Server migration detected during upload check. ' + - 'Empty server with previously synced ops. Creating full state SYNC_IMPORT.', - ); - await this._handleServerMigration(); - } - - /** - * Handles server migration scenario by creating a SYNC_IMPORT operation - * with the full current state. - * - * This is called when: - * 1. Client has existing data (lastServerSeq > 0 from old server) - * 2. Server returns gapDetected: true (client seq ahead of server) - * 3. Server is empty (no ops to download) - * - * This indicates the client has connected to a new/reset server. - * Without uploading full state, incremental ops would reference - * entities that don't exist on the new server. - */ - private async _handleServerMigration(): Promise { - OpLog.warn( - 'OperationLogSyncService: Server migration detected. Creating full state SYNC_IMPORT.', - ); - - // Get current full state from NgRx store - let currentState = await this.storeDelegateService.getAllSyncModelDataFromStore(); - - // Skip if local state is effectively empty - if (this._isEmptyState(currentState)) { - OpLog.warn('OperationLogSyncService: Skipping SYNC_IMPORT - local state is empty.'); - return; - } - - // Validate and repair state before creating SYNC_IMPORT - // This prevents corrupted state (e.g., orphaned menuTree references) from - // propagating to other clients via the full state import. - const validationResult = this.validateStateService.validateAndRepair( - currentState as AppDataCompleteNew, - ); - - // If state is invalid and couldn't be repaired, abort - don't propagate corruption - if (!validationResult.isValid) { - OpLog.err( - 'OperationLogSyncService: Cannot create SYNC_IMPORT - state validation failed.', - validationResult.error || validationResult.crossModelError, - ); - this.snackService.open({ - type: 'ERROR', - msg: T.F.SYNC.S.SERVER_MIGRATION_VALIDATION_FAILED, - }); - return; - } - - // If state was repaired, use the repaired version - if (validationResult.repairedState) { - OpLog.warn( - 'OperationLogSyncService: State repaired before creating SYNC_IMPORT', - validationResult.repairSummary, - ); - currentState = validationResult.repairedState; - - // Also update NgRx store with repaired state so local client is consistent - this.store.dispatch( - loadAllData({ appDataComplete: validationResult.repairedState }), - ); - } - - // Get client ID and vector clock - const clientId = await this._getPfapiService().pf.metaModel.loadClientId(); - if (!clientId) { - OpLog.err( - 'OperationLogSyncService: Cannot create SYNC_IMPORT - no client ID available.', - ); - return; - } - - const currentClock = await this.vectorClockService.getCurrentVectorClock(); - const newClock = incrementVectorClock(currentClock, clientId); - - // Create SYNC_IMPORT operation with full state - // NOTE: Use raw state directly (not wrapped in appDataComplete). - // The snapshot endpoint expects raw state, and the hydrator handles - // both formats on extraction. - const op: Operation = { - id: uuidv7(), - actionType: '[SP_ALL] Load(import) all data', - opType: OpType.SyncImport, - entityType: 'ALL', - payload: currentState, - clientId, - vectorClock: newClock, - timestamp: Date.now(), - schemaVersion: CURRENT_SCHEMA_VERSION, - }; - - // Append to operation log - will be uploaded via snapshot endpoint - await this.opLogStore.append(op, 'local'); - - OpLog.normal( - 'OperationLogSyncService: Created SYNC_IMPORT operation for server migration. ' + - 'Will be uploaded immediately via follow-up upload.', - ); - } - - /** - * Checks if the state is effectively empty (no meaningful data to sync). - * An empty state has no tasks, projects, or tags. - */ - private _isEmptyState(state: unknown): boolean { - if (!state || typeof state !== 'object') { - return true; - } - - const s = state as Record; - - // Check for meaningful data in key entity collections - const taskState = s['task'] as { ids?: unknown[] } | undefined; - const projectState = s['project'] as { ids?: unknown[] } | undefined; - const tagState = s['tag'] as { ids?: (string | unknown)[] } | undefined; - - const hasNoTasks = !taskState?.ids || taskState.ids.length === 0; - const hasNoProjects = !projectState?.ids || projectState.ids.length === 0; - const hasNoUserTags = this._hasNoUserCreatedTags(tagState?.ids); - - // Consider empty if there are no tasks, projects, or user-defined tags - return hasNoTasks && hasNoProjects && hasNoUserTags; - } - - /** - * Checks if there are no user-created tags. - * System tags (TODAY, URGENT, IMPORTANT, IN_PROGRESS) are excluded from the count. - */ - private _hasNoUserCreatedTags(tagIds: (string | unknown)[] | undefined): boolean { - if (!tagIds || tagIds.length === 0) { - return true; - } - const userTagCount = tagIds.filter( - (id) => typeof id === 'string' && !SYSTEM_TAG_IDS.has(id), - ).length; - return userTagCount === 0; - } - /** * Checks if the primary target entities of an operation exist in the current store. * diff --git a/src/app/core/persistence/operation-log/sync/server-migration.service.spec.ts b/src/app/core/persistence/operation-log/sync/server-migration.service.spec.ts new file mode 100644 index 000000000..0c0c04f3b --- /dev/null +++ b/src/app/core/persistence/operation-log/sync/server-migration.service.spec.ts @@ -0,0 +1,409 @@ +/* eslint-disable @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any */ +import { TestBed } from '@angular/core/testing'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { ServerMigrationService } from './server-migration.service'; +import { OperationLogStoreService } from '../store/operation-log-store.service'; +import { VectorClockService } from './vector-clock.service'; +import { ValidateStateService } from '../processing/validate-state.service'; +import { PfapiStoreDelegateService } from '../../../../pfapi/pfapi-store-delegate.service'; +import { SnackService } from '../../../snack/snack.service'; +import { PfapiService } from '../../../../pfapi/pfapi.service'; +import { + SyncProviderServiceInterface, + OperationSyncCapable, +} from '../../../../pfapi/api/sync/sync-provider.interface'; +import { SyncProviderId } from '../../../../pfapi/api/pfapi.const'; +import { OpType } from '../operation.types'; +import { SYSTEM_TAG_IDS } from '../../../../features/tag/tag.const'; +import { loadAllData } from '../../../../root-store/meta/load-all-data.action'; + +describe('ServerMigrationService', () => { + let service: ServerMigrationService; + let store: MockStore; + let opLogStoreSpy: jasmine.SpyObj; + let vectorClockServiceSpy: jasmine.SpyObj; + let validateStateServiceSpy: jasmine.SpyObj; + let storeDelegateServiceSpy: jasmine.SpyObj; + let snackServiceSpy: jasmine.SpyObj; + let pfapiServiceSpy: any; + + // Type for operation-sync-capable provider + type OperationSyncProvider = SyncProviderServiceInterface & + OperationSyncCapable; + + // Mock sync provider that supports operations + const createMockSyncProvider = (): OperationSyncProvider => { + return { + supportsOperationSync: true, + id: 'SuperSync' as SyncProviderId, + maxConcurrentRequests: 10, + getLastServerSeq: jasmine + .createSpy('getLastServerSeq') + .and.returnValue(Promise.resolve(0)), + downloadOps: jasmine + .createSpy('downloadOps') + .and.returnValue(Promise.resolve({ ops: [], latestSeq: 0, hasMore: false })), + uploadOps: jasmine.createSpy('uploadOps'), + uploadSnapshot: jasmine.createSpy('uploadSnapshot'), + setLastServerSeq: jasmine.createSpy('setLastServerSeq'), + privateCfg: {} as any, + getFileRev: jasmine.createSpy('getFileRev'), + downloadFile: jasmine.createSpy('downloadFile'), + uploadFile: jasmine.createSpy('uploadFile'), + removeFile: jasmine.createSpy('removeFile'), + isReady: jasmine.createSpy('isReady'), + setPrivateCfg: jasmine.createSpy('setPrivateCfg'), + } as unknown as OperationSyncProvider; + }; + + // Mock sync provider that does NOT support operations + const createLegacySyncProvider = (): SyncProviderServiceInterface => { + return { + id: 'WebDAV' as SyncProviderId, + maxConcurrentRequests: 5, + privateCfg: {} as any, + getFileRev: jasmine.createSpy('getFileRev'), + downloadFile: jasmine.createSpy('downloadFile'), + uploadFile: jasmine.createSpy('uploadFile'), + removeFile: jasmine.createSpy('removeFile'), + isReady: jasmine.createSpy('isReady'), + setPrivateCfg: jasmine.createSpy('setPrivateCfg'), + } as unknown as SyncProviderServiceInterface; + }; + + beforeEach(() => { + opLogStoreSpy = jasmine.createSpyObj('OperationLogStoreService', [ + 'hasSyncedOps', + 'append', + ]); + vectorClockServiceSpy = jasmine.createSpyObj('VectorClockService', [ + 'getCurrentVectorClock', + ]); + validateStateServiceSpy = jasmine.createSpyObj('ValidateStateService', [ + 'validateAndRepair', + ]); + storeDelegateServiceSpy = jasmine.createSpyObj('PfapiStoreDelegateService', [ + 'getAllSyncModelDataFromStore', + ]); + snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']); + + // Mock PfapiService + pfapiServiceSpy = { + pf: { + metaModel: { + loadClientId: jasmine + .createSpy('loadClientId') + .and.returnValue(Promise.resolve('test-client')), + }, + }, + }; + + // Default mock returns + opLogStoreSpy.hasSyncedOps.and.returnValue(Promise.resolve(true)); + opLogStoreSpy.append.and.returnValue(Promise.resolve(1)); + vectorClockServiceSpy.getCurrentVectorClock.and.returnValue( + Promise.resolve({ 'test-client': 5 }), + ); + validateStateServiceSpy.validateAndRepair.and.returnValue({ + isValid: true, + wasRepaired: false, + } as any); + storeDelegateServiceSpy.getAllSyncModelDataFromStore.and.returnValue( + Promise.resolve({ + task: { + ids: ['task-1'], + entities: { 'task-1': { id: 'task-1', title: 'Test' } }, + }, + project: { ids: [], entities: {} }, + tag: { ids: [], entities: {} }, + }) as any, + ); + + TestBed.configureTestingModule({ + providers: [ + ServerMigrationService, + provideMockStore(), + { provide: OperationLogStoreService, useValue: opLogStoreSpy }, + { provide: VectorClockService, useValue: vectorClockServiceSpy }, + { provide: ValidateStateService, useValue: validateStateServiceSpy }, + { provide: PfapiStoreDelegateService, useValue: storeDelegateServiceSpy }, + { provide: SnackService, useValue: snackServiceSpy }, + { provide: PfapiService, useValue: pfapiServiceSpy }, + ], + }); + + service = TestBed.inject(ServerMigrationService); + store = TestBed.inject(MockStore); + spyOn(store, 'dispatch'); + }); + + describe('checkAndHandleMigration', () => { + it('should skip for non-operation-sync-capable providers', async () => { + const legacyProvider = createLegacySyncProvider(); + await service.checkAndHandleMigration(legacyProvider); + + expect(opLogStoreSpy.hasSyncedOps).not.toHaveBeenCalled(); + expect(opLogStoreSpy.append).not.toHaveBeenCalled(); + }); + + it('should skip if lastServerSeq !== 0 (already synced with server)', async () => { + const provider = createMockSyncProvider(); + (provider.getLastServerSeq as jasmine.Spy).and.returnValue(Promise.resolve(10)); + + await service.checkAndHandleMigration(provider); + + expect(provider.downloadOps).not.toHaveBeenCalled(); + expect(opLogStoreSpy.append).not.toHaveBeenCalled(); + }); + + it('should skip if server has data (latestSeq !== 0)', async () => { + const provider = createMockSyncProvider(); + (provider.getLastServerSeq as jasmine.Spy).and.returnValue(Promise.resolve(0)); + (provider.downloadOps as jasmine.Spy).and.returnValue( + Promise.resolve({ ops: [], latestSeq: 5, hasMore: false }), + ); + + await service.checkAndHandleMigration(provider); + + expect(opLogStoreSpy.hasSyncedOps).not.toHaveBeenCalled(); + expect(opLogStoreSpy.append).not.toHaveBeenCalled(); + }); + + it('should skip if client has no previously synced ops (fresh client)', async () => { + const provider = createMockSyncProvider(); + opLogStoreSpy.hasSyncedOps.and.returnValue(Promise.resolve(false)); + + await service.checkAndHandleMigration(provider); + + expect(opLogStoreSpy.append).not.toHaveBeenCalled(); + }); + + it('should call handleServerMigration when all conditions are met', async () => { + const provider = createMockSyncProvider(); + (provider.getLastServerSeq as jasmine.Spy).and.returnValue(Promise.resolve(0)); + (provider.downloadOps as jasmine.Spy).and.returnValue( + Promise.resolve({ ops: [], latestSeq: 0, hasMore: false }), + ); + opLogStoreSpy.hasSyncedOps.and.returnValue(Promise.resolve(true)); + + await service.checkAndHandleMigration(provider); + + expect(opLogStoreSpy.append).toHaveBeenCalled(); + const appendedOp = opLogStoreSpy.append.calls.mostRecent().args[0]; + expect(appendedOp.opType).toBe(OpType.SyncImport); + }); + }); + + describe('handleServerMigration', () => { + it('should skip if state is empty (no tasks/projects/tags)', async () => { + storeDelegateServiceSpy.getAllSyncModelDataFromStore.and.returnValue( + Promise.resolve({ + task: { ids: [], entities: {} }, + project: { ids: [], entities: {} }, + tag: { ids: [], entities: {} }, + }) as any, + ); + + await service.handleServerMigration(); + + expect(opLogStoreSpy.append).not.toHaveBeenCalled(); + }); + + it('should skip if state only has system tags', async () => { + const systemTagIds = Array.from(SYSTEM_TAG_IDS); + storeDelegateServiceSpy.getAllSyncModelDataFromStore.and.returnValue( + Promise.resolve({ + task: { ids: [], entities: {} }, + project: { ids: [], entities: {} }, + tag: { ids: systemTagIds, entities: {} }, + }) as any, + ); + + await service.handleServerMigration(); + + expect(opLogStoreSpy.append).not.toHaveBeenCalled(); + }); + + it('should abort if state validation fails', async () => { + validateStateServiceSpy.validateAndRepair.and.returnValue({ + isValid: false, + wasRepaired: false, + error: 'Validation failed', + } as any); + + await service.handleServerMigration(); + + expect(opLogStoreSpy.append).not.toHaveBeenCalled(); + expect(snackServiceSpy.open).toHaveBeenCalledWith( + jasmine.objectContaining({ type: 'ERROR' }), + ); + }); + + it('should use repaired state and dispatch to store if repair occurred', async () => { + const repairedState = { + task: { + ids: ['task-1'], + entities: { 'task-1': { id: 'task-1', title: 'Repaired' } }, + }, + project: { ids: [], entities: {} }, + tag: { ids: [], entities: {} }, + }; + + validateStateServiceSpy.validateAndRepair.and.returnValue({ + isValid: true, + wasRepaired: true, + repairedState, + repairSummary: 'Fixed orphaned references', + } as any); + + await service.handleServerMigration(); + + expect(store.dispatch).toHaveBeenCalledWith( + loadAllData({ appDataComplete: repairedState as any }), + ); + + expect(opLogStoreSpy.append).toHaveBeenCalled(); + const appendedOp = opLogStoreSpy.append.calls.mostRecent().args[0]; + expect(appendedOp.payload).toBe(repairedState); + }); + + it('should create SYNC_IMPORT with correct structure', async () => { + const mockState = { + task: { ids: ['task-1'], entities: { 'task-1': { id: 'task-1' } } }, + project: { ids: [], entities: {} }, + tag: { ids: [], entities: {} }, + }; + storeDelegateServiceSpy.getAllSyncModelDataFromStore.and.returnValue( + Promise.resolve(mockState) as any, + ); + vectorClockServiceSpy.getCurrentVectorClock.and.returnValue( + Promise.resolve({ 'test-client': 5 }), + ); + + await service.handleServerMigration(); + + expect(opLogStoreSpy.append).toHaveBeenCalled(); + const appendedOp = opLogStoreSpy.append.calls.mostRecent().args[0]; + expect(appendedOp.opType).toBe(OpType.SyncImport); + expect(appendedOp.entityType).toBe('ALL'); + expect(appendedOp.clientId).toBe('test-client'); + expect(appendedOp.payload).toBe(mockState); + expect(appendedOp.vectorClock['test-client']).toBe(6); + }); + + it('should abort if no client ID is available', async () => { + pfapiServiceSpy.pf.metaModel.loadClientId.and.returnValue(Promise.resolve(null)); + + await service.handleServerMigration(); + + expect(opLogStoreSpy.append).not.toHaveBeenCalled(); + }); + + it('should proceed if state has tasks', async () => { + storeDelegateServiceSpy.getAllSyncModelDataFromStore.and.returnValue( + Promise.resolve({ + task: { ids: ['task-1'], entities: { 'task-1': { id: 'task-1' } } }, + project: { ids: [], entities: {} }, + tag: { ids: [], entities: {} }, + }) as any, + ); + + await service.handleServerMigration(); + + expect(opLogStoreSpy.append).toHaveBeenCalled(); + }); + + it('should proceed if state has projects', async () => { + storeDelegateServiceSpy.getAllSyncModelDataFromStore.and.returnValue( + Promise.resolve({ + task: { ids: [], entities: {} }, + project: { ids: ['proj-1'], entities: { 'proj-1': { id: 'proj-1' } } }, + tag: { ids: [], entities: {} }, + }) as any, + ); + + await service.handleServerMigration(); + + expect(opLogStoreSpy.append).toHaveBeenCalled(); + }); + + it('should proceed if state has user-created tags', async () => { + storeDelegateServiceSpy.getAllSyncModelDataFromStore.and.returnValue( + Promise.resolve({ + task: { ids: [], entities: {} }, + project: { ids: [], entities: {} }, + tag: { ids: ['user-tag-1'], entities: { 'user-tag-1': { id: 'user-tag-1' } } }, + }) as any, + ); + + await service.handleServerMigration(); + + expect(opLogStoreSpy.append).toHaveBeenCalled(); + }); + }); + + describe('_isEmptyState (tested via handleServerMigration)', () => { + it('should treat null state as empty', async () => { + storeDelegateServiceSpy.getAllSyncModelDataFromStore.and.returnValue( + Promise.resolve(null) as any, + ); + + await service.handleServerMigration(); + + expect(opLogStoreSpy.append).not.toHaveBeenCalled(); + }); + + it('should treat undefined state as empty', async () => { + storeDelegateServiceSpy.getAllSyncModelDataFromStore.and.returnValue( + Promise.resolve(undefined) as any, + ); + + await service.handleServerMigration(); + + expect(opLogStoreSpy.append).not.toHaveBeenCalled(); + }); + + it('should treat non-object state as empty', async () => { + storeDelegateServiceSpy.getAllSyncModelDataFromStore.and.returnValue( + Promise.resolve('not an object') as any, + ); + + await service.handleServerMigration(); + + expect(opLogStoreSpy.append).not.toHaveBeenCalled(); + }); + }); + + describe('_hasNoUserCreatedTags (tested via handleServerMigration)', () => { + it('should identify system tags correctly', async () => { + for (const systemTagId of SYSTEM_TAG_IDS) { + opLogStoreSpy.append.calls.reset(); + storeDelegateServiceSpy.getAllSyncModelDataFromStore.and.returnValue( + Promise.resolve({ + task: { ids: [], entities: {} }, + project: { ids: [], entities: {} }, + tag: { ids: [systemTagId], entities: {} }, + }) as any, + ); + + await service.handleServerMigration(); + + expect(opLogStoreSpy.append).not.toHaveBeenCalled(); + } + }); + + it('should proceed with mixed system and user tags', async () => { + storeDelegateServiceSpy.getAllSyncModelDataFromStore.and.returnValue( + Promise.resolve({ + task: { ids: [], entities: {} }, + project: { ids: [], entities: {} }, + tag: { ids: ['TODAY', 'user-custom-tag'], entities: {} }, + }) as any, + ); + + await service.handleServerMigration(); + + expect(opLogStoreSpy.append).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/core/persistence/operation-log/sync/server-migration.service.ts b/src/app/core/persistence/operation-log/sync/server-migration.service.ts new file mode 100644 index 000000000..9cd285afc --- /dev/null +++ b/src/app/core/persistence/operation-log/sync/server-migration.service.ts @@ -0,0 +1,256 @@ +import { inject, Injectable, Injector } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { SyncProviderServiceInterface } from '../../../../pfapi/api/sync/sync-provider.interface'; +import { SyncProviderId } from '../../../../pfapi/api/pfapi.const'; +import { isOperationSyncCapable } from './operation-sync.util'; +import { OperationLogStoreService } from '../store/operation-log-store.service'; +import { VectorClockService } from './vector-clock.service'; +import { incrementVectorClock } from '../../../../pfapi/api/util/vector-clock'; +import { PfapiStoreDelegateService } from '../../../../pfapi/pfapi-store-delegate.service'; +import { ValidateStateService } from '../processing/validate-state.service'; +import { AppDataCompleteNew } from '../../../../pfapi/pfapi-config'; +import { SnackService } from '../../../snack/snack.service'; +import { T } from '../../../../t.const'; +import { loadAllData } from '../../../../root-store/meta/load-all-data.action'; +import { CURRENT_SCHEMA_VERSION } from '../store/schema-migration.service'; +import { Operation, OpType } from '../operation.types'; +import { uuidv7 } from '../../../../util/uuid-v7'; +import { OpLog } from '../../../log'; +import { SYSTEM_TAG_IDS } from '../../../../features/tag/tag.const'; +import { PfapiService } from '../../../../pfapi/pfapi.service'; +import { lazyInject } from '../../../../util/lazy-inject'; + +/** + * Service responsible for handling server migration scenarios. + * + * ## What is Server Migration? + * Server migration occurs when a client with existing synced data connects to + * a new/empty sync server. This can happen when: + * 1. User switches to a new sync provider + * 2. Sync server is reset/cleared + * 3. User restores from a backup on a fresh server + * + * ## Why is it needed? + * Without server migration handling, incremental operations uploaded to the new + * server would reference entities (tasks, projects, tags) that don't exist on + * the server, causing sync failures for other clients. + * + * ## The Solution + * When migration is detected, this service creates a SYNC_IMPORT operation + * containing the full current state. This ensures all entities exist on the + * server before incremental operations are applied. + */ +@Injectable({ + providedIn: 'root', +}) +export class ServerMigrationService { + private store = inject(Store); + private opLogStore = inject(OperationLogStoreService); + private vectorClockService = inject(VectorClockService); + private validateStateService = inject(ValidateStateService); + private storeDelegateService = inject(PfapiStoreDelegateService); + private snackService = inject(SnackService); + + // Lazy injection to break circular dependency: + // PfapiService -> Pfapi -> OperationLogSyncService -> ServerMigrationService -> PfapiService + private _injector = inject(Injector); + private _getPfapiService = lazyInject(this._injector, PfapiService); + + /** + * Checks if we're connecting to a new/empty server and handles migration if needed. + * + * ## Detection Logic + * Server migration is detected when ALL of these conditions are true: + * 1. This is a sync-capable provider (supports operation-based sync) + * 2. lastServerSeq is 0 (first time connecting to this server) + * 3. Server is empty (no operations to download) + * 4. Client has PREVIOUSLY synced operations (not a fresh client) + * + * ## Why "previously synced" matters + * A fresh client with only local (unsynced) ops is NOT a migration scenario. + * Fresh clients should just upload their ops normally without creating a SYNC_IMPORT. + * + * @param syncProvider - The sync provider to check against + */ + async checkAndHandleMigration( + syncProvider: SyncProviderServiceInterface, + ): Promise { + // Only check for operation-sync capable providers + if (!isOperationSyncCapable(syncProvider)) { + return; + } + + // Check if lastServerSeq is 0 (first time connecting to this server) + const lastServerSeq = await syncProvider.getLastServerSeq(); + if (lastServerSeq !== 0) { + // We've synced with this server before, no migration needed + return; + } + + // Check if server is empty by doing a minimal download request + const response = await syncProvider.downloadOps(0, undefined, 1); + if (response.latestSeq !== 0) { + // Server has data, this is not a migration scenario + // (might be joining an existing sync group) + return; + } + + // CRITICAL: Check if this client has PREVIOUSLY synced operations. + // A client that has never synced (only local ops) is NOT a migration case. + // It's just a fresh client that should upload its ops normally. + const hasSyncedOps = await this.opLogStore.hasSyncedOps(); + if (!hasSyncedOps) { + OpLog.normal( + 'ServerMigrationService: Empty server detected, but no previously synced ops. ' + + 'This is a fresh client, not a server migration. Proceeding with normal upload.', + ); + return; + } + + // Server is empty AND we have PREVIOUSLY SYNCED ops AND lastServerSeq is 0 + // This is a server migration - create SYNC_IMPORT with full state + OpLog.warn( + 'ServerMigrationService: Server migration detected during upload check. ' + + 'Empty server with previously synced ops. Creating full state SYNC_IMPORT.', + ); + await this.handleServerMigration(); + } + + /** + * Handles server migration by creating a SYNC_IMPORT operation with full current state. + * + * ## Process + * 1. Get current state from NgRx store + * 2. Skip if state is empty (nothing to migrate) + * 3. Validate and repair state (prevent propagating corruption) + * 4. Create SYNC_IMPORT operation with full state + * 5. Append to operation log for upload + * + * ## State Validation + * Before creating SYNC_IMPORT, the state is validated and repaired if needed. + * This prevents corrupted state (e.g., orphaned references) from propagating + * to other clients via the full state import. + */ + async handleServerMigration(): Promise { + OpLog.warn( + 'ServerMigrationService: Server migration detected. Creating full state SYNC_IMPORT.', + ); + + // Get current full state from NgRx store + let currentState = await this.storeDelegateService.getAllSyncModelDataFromStore(); + + // Skip if local state is effectively empty + if (this._isEmptyState(currentState)) { + OpLog.warn('ServerMigrationService: Skipping SYNC_IMPORT - local state is empty.'); + return; + } + + // Validate and repair state before creating SYNC_IMPORT + // This prevents corrupted state (e.g., orphaned menuTree references) from + // propagating to other clients via the full state import. + const validationResult = this.validateStateService.validateAndRepair( + currentState as AppDataCompleteNew, + ); + + // If state is invalid and couldn't be repaired, abort - don't propagate corruption + if (!validationResult.isValid) { + OpLog.err( + 'ServerMigrationService: Cannot create SYNC_IMPORT - state validation failed.', + validationResult.error || validationResult.crossModelError, + ); + this.snackService.open({ + type: 'ERROR', + msg: T.F.SYNC.S.SERVER_MIGRATION_VALIDATION_FAILED, + }); + return; + } + + // If state was repaired, use the repaired version + if (validationResult.repairedState) { + OpLog.warn( + 'ServerMigrationService: State repaired before creating SYNC_IMPORT', + validationResult.repairSummary, + ); + currentState = validationResult.repairedState; + + // Also update NgRx store with repaired state so local client is consistent + this.store.dispatch( + loadAllData({ appDataComplete: validationResult.repairedState }), + ); + } + + // Get client ID and vector clock + const clientId = await this._getPfapiService().pf.metaModel.loadClientId(); + if (!clientId) { + OpLog.err( + 'ServerMigrationService: Cannot create SYNC_IMPORT - no client ID available.', + ); + return; + } + + const currentClock = await this.vectorClockService.getCurrentVectorClock(); + const newClock = incrementVectorClock(currentClock, clientId); + + // Create SYNC_IMPORT operation with full state + // NOTE: Use raw state directly (not wrapped in appDataComplete). + // The snapshot endpoint expects raw state, and the hydrator handles + // both formats on extraction. + const op: Operation = { + id: uuidv7(), + actionType: '[SP_ALL] Load(import) all data', + opType: OpType.SyncImport, + entityType: 'ALL', + payload: currentState, + clientId, + vectorClock: newClock, + timestamp: Date.now(), + schemaVersion: CURRENT_SCHEMA_VERSION, + }; + + // Append to operation log - will be uploaded via snapshot endpoint + await this.opLogStore.append(op, 'local'); + + OpLog.normal( + 'ServerMigrationService: Created SYNC_IMPORT operation for server migration. ' + + 'Will be uploaded immediately via follow-up upload.', + ); + } + + /** + * Checks if the state is effectively empty (no meaningful data to sync). + * An empty state has no tasks, projects, or user-created tags. + */ + private _isEmptyState(state: unknown): boolean { + if (!state || typeof state !== 'object') { + return true; + } + + const s = state as Record; + + // Check for meaningful data in key entity collections + const taskState = s['task'] as { ids?: unknown[] } | undefined; + const projectState = s['project'] as { ids?: unknown[] } | undefined; + const tagState = s['tag'] as { ids?: (string | unknown)[] } | undefined; + + const hasNoTasks = !taskState?.ids || taskState.ids.length === 0; + const hasNoProjects = !projectState?.ids || projectState.ids.length === 0; + const hasNoUserTags = this._hasNoUserCreatedTags(tagState?.ids); + + // Consider empty if there are no tasks, projects, or user-defined tags + return hasNoTasks && hasNoProjects && hasNoUserTags; + } + + /** + * Checks if there are no user-created tags. + * System tags (TODAY, URGENT, IMPORTANT, IN_PROGRESS) are excluded from the count. + */ + private _hasNoUserCreatedTags(tagIds: (string | unknown)[] | undefined): boolean { + if (!tagIds || tagIds.length === 0) { + return true; + } + const userTagCount = tagIds.filter( + (id) => typeof id === 'string' && !SYSTEM_TAG_IDS.has(id), + ).length; + return userTagCount === 0; + } +}