fix(migrations): ensure unique IDs and prevent data loss in split operations

When splitting one operation into misc and tasks operations:
- Both operations now get unique ID suffixes (_misc and _tasks)
- Skip logic verifies both conditions before skipping:
  1. tasks config has migrated field (isConfirmBeforeDelete)
  2. misc config has NO migrated fields remaining

This prevents:
- Potential operation ID conflicts in the sync log
- Data loss in partial migration scenarios

Adds test cases for partial migration and correct skip scenarios.
This commit is contained in:
Johannes Millan 2026-01-19 17:31:13 +01:00
parent c42d5ace43
commit be4b8ba241
2 changed files with 44 additions and 6 deletions

View file

@ -30,8 +30,8 @@ export const MiscToTasksSettingsMigration_v1v2: SchemaMigration = {
const tasks = state.globalConfig?.tasks ?? {};
// Skip if already migrated (tasks has new fields)
if (tasks.isConfirmBeforeDelete !== undefined) {
// Skip if already migrated (tasks has new fields AND misc has no migrated fields)
if (tasks.isConfirmBeforeDelete !== undefined && !hasMigratedFields(misc)) {
return state;
}
@ -94,13 +94,17 @@ export const MiscToTasksSettingsMigration_v1v2: SchemaMigration = {
const result: OperationLike[] = [];
if (Object.keys(miscCfg).length > 0) {
result.push({ ...op, payload: buildPayload(miscCfg, 'misc') });
result.push({
...op,
id: `${op.id}_misc`,
payload: buildPayload(miscCfg, 'misc'),
});
}
if (Object.keys(tasksCfg).length > 0) {
result.push({
...op,
id: `${op.id}_tasks_migrated`,
id: `${op.id}_tasks`,
entityId: 'tasks',
payload: buildPayload(tasksCfg, 'tasks'),
});

View file

@ -64,14 +64,46 @@ describe('Migrate MiscConfig to TasksConfig', () => {
expect(migratedState).toEqual(initialState);
});
it('should not modify state if tasks already migrated', () => {
it('should complete migration even if tasks.isConfirmBeforeDelete exists but misc still has migrated fields', () => {
const initialState = {
globalConfig: {
misc: {
isConfirmBeforeTaskDelete: true,
defaultProjectId: 'proj-123',
someOtherField: 'value',
},
tasks: {
isConfirmBeforeDelete: false, // Already exists
existingField: 'existing',
},
},
};
const migratedState = migration.migrateState(initialState) as {
globalConfig: {
misc: Record<string, unknown>;
tasks: Record<string, unknown>;
};
};
// Should migrate remaining fields from misc
expect(migratedState.globalConfig.tasks.isConfirmBeforeDelete).toBe(true); // Migrated value overwrites
expect(migratedState.globalConfig.tasks.defaultProjectId).toBe('proj-123'); // Migrated
expect(migratedState.globalConfig.tasks.existingField).toBe('existing'); // Preserved
expect(migratedState.globalConfig.misc.isConfirmBeforeTaskDelete).toBeUndefined(); // Removed
expect(migratedState.globalConfig.misc.defaultProjectId).toBeUndefined(); // Removed
expect(migratedState.globalConfig.misc.someOtherField).toBe('value'); // Preserved
});
it('should skip migration if tasks already has migrated fields AND misc has no migrated fields', () => {
const initialState = {
globalConfig: {
misc: {
someOtherField: 'value', // Not a migrated field
},
tasks: {
isConfirmBeforeDelete: true,
defaultProjectId: 'proj-123',
},
},
};
@ -111,6 +143,7 @@ describe('Migrate MiscConfig to TasksConfig', () => {
// First operation should be misc with non-migrated settings
const miscOp = resultArray.find((r) => r.entityId === 'misc');
expect(miscOp).toBeDefined();
expect(miscOp!.id).toBe('op_1_misc');
expect(miscOp!.payload).toEqual({
isMinimizeToTray: false,
});
@ -118,7 +151,7 @@ describe('Migrate MiscConfig to TasksConfig', () => {
// Second operation should be tasks with migrated settings
const tasksOp = resultArray.find((r) => r.entityId === 'tasks');
expect(tasksOp).toBeDefined();
expect(tasksOp!.id).toBe('op_1_tasks_migrated');
expect(tasksOp!.id).toBe('op_1_tasks');
expect(tasksOp!.payload).toEqual({
isConfirmBeforeDelete: true,
isMarkdownFormattingInNotesEnabled: false, // Inverted from isTurnOffMarkdown: true
@ -143,6 +176,7 @@ describe('Migrate MiscConfig to TasksConfig', () => {
// Should return single tasks operation
expect(Array.isArray(result)).toBe(false);
const singleOp = result as OperationLike;
expect(singleOp.id).toBe('op_2_tasks');
expect(singleOp.entityId).toBe('tasks');
expect(singleOp.payload).toEqual({
isAutoAddWorkedOnToToday: true,