refactor(sync): extract server migration logic to dedicated service

Extract server migration detection and handling from OperationLogSyncService
to a new ServerMigrationService for improved maintainability.

Extracted functionality:
- checkAndHandleMigration: Detects empty server with previously synced ops
- handleServerMigration: Creates SYNC_IMPORT with full validated state
- _isEmptyState: Checks if state has meaningful data to sync
- _hasNoUserCreatedTags: Excludes system tags from empty state check

Changes:
- Create ServerMigrationService (257 lines)
- Create server-migration.service.spec.ts (19 tests)
- Update OperationLogSyncService to delegate to new service
- Remove ~200 lines from OperationLogSyncService (1714 → 1521 lines)

All existing tests continue to pass. Conflict resolution logic untouched.
This commit is contained in:
Johannes Millan 2025-12-18 18:01:49 +01:00
parent d7ae49f75c
commit d54156dda3
4 changed files with 683 additions and 811 deletions

View file

@ -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<LockService>;
let compactionServiceSpy: jasmine.SpyObj<OperationLogCompactionService>;
let syncImportFilterServiceSpy: jasmine.SpyObj<SyncImportFilterService>;
let serverMigrationServiceSpy: jasmine.SpyObj<ServerMigrationService>;
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<any>(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<PfapiStoreDelegateService>;
let mockStore: jasmine.SpyObj<any>;
beforeEach(() => {
storeDelegateSpy = TestBed.inject(
PfapiStoreDelegateService,
) as jasmine.SpyObj<PfapiStoreDelegateService>;
mockStore = TestBed.inject(Store) as jasmine.SpyObj<any>;
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<OperationLogDownloadService>;
beforeEach(() => {
downloadServiceSpy = TestBed.inject(
OperationLogDownloadService,
) as jasmine.SpyObj<OperationLogDownloadService>;
});
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.
});

View file

@ -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<SyncProviderId>,
): Promise<void> {
// 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<void> {
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<string, unknown>;
// 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.
*

View file

@ -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<OperationLogStoreService>;
let vectorClockServiceSpy: jasmine.SpyObj<VectorClockService>;
let validateStateServiceSpy: jasmine.SpyObj<ValidateStateService>;
let storeDelegateServiceSpy: jasmine.SpyObj<PfapiStoreDelegateService>;
let snackServiceSpy: jasmine.SpyObj<SnackService>;
let pfapiServiceSpy: any;
// Type for operation-sync-capable provider
type OperationSyncProvider = SyncProviderServiceInterface<SyncProviderId> &
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<SyncProviderId> => {
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<SyncProviderId>;
};
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();
});
});
});

View file

@ -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<SyncProviderId>,
): Promise<void> {
// 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<void> {
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<string, unknown>;
// 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;
}
}