mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
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:
parent
d7ae49f75c
commit
d54156dda3
4 changed files with 683 additions and 811 deletions
|
|
@ -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.
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue