fix(migration): preserve and recover legacy desktop sources (#1550)

* fix(migration): recover legacy desktop sources without replacing current data

* test(migration): cover legacy recovery IPC contracts

* test(migration): use static legacy Electron bootstrap
This commit is contained in:
4gray authored and GitHub committed 2026-09-06 00:45:35 +02:00
1 parent 002faafbd7
commit 9bcdbc0efb
26 files changed
+1505 -38

No files matched your search

@@ -0,0 +1,68 @@
import { firstValueFrom, of } from 'rxjs';
import { PlaylistsService } from './playlists.service';
describe('Electron legacy playlist migration', () => {
const original = window.electron;
afterEach(() => {
window.electron = original;
jest.restoreAllMocks();
});
function setup() {
const playlists = [
{ _id: 'stalker-1', macAddress: '00:1A:79:00:00:01' },
{ _id: 'xtream-1', serverUrl: 'https://synthetic.invalid' },
{ _id: 'm3u-1', playlist: { items: [] } },
];
const dbService = {
getAll: jest.fn(() => of(playlists)),
clear: jest.fn(() => of(undefined)),
};
const electron = {
dbGetAppState: jest.fn(async () => null),
dbSetAppState: jest.fn(async () => undefined),
dbGetAppPlaylists: jest.fn(async () => []),
dbUpsertAppPlaylists: jest.fn(async () => ({
success: true,
count: playlists.length,
})),
dbMigrateAppPlaylists: jest.fn(async () => ({
success: true,
count: playlists.length,
})),
};
window.electron = electron as unknown as typeof window.electron;
const service = Object.create(
PlaylistsService.prototype
) as PlaylistsService;
Object.assign(service, {
dbService,
runtime: { supportsSqlite: true },
electronMigrationPromise: null,
});
return { playlists, dbService, electron, service };
}
it('retains legacy IndexedDB and uses the atomic non-overwriting migration', async () => {
const { playlists, dbService, electron, service } = setup();
await firstValueFrom(service.getAllPlaylists());
expect(electron.dbMigrateAppPlaylists).toHaveBeenCalledWith(playlists);
expect(dbService.clear).not.toHaveBeenCalled();
expect(electron.dbSetAppState).not.toHaveBeenCalledWith(
'm3u-playlists-indexeddb-to-sqlite-v1',
'1'
);
});
it('retries failed migration and does not present an incomplete source list as success', async () => {
const { electron, service } = setup();
jest.spyOn(console, 'error').mockImplementation(() => undefined);
electron.dbMigrateAppPlaylists.mockRejectedValueOnce(
new Error('synthetic failure')
);
await expect(firstValueFrom(service.getAllPlaylists())).rejects.toThrow(
'synthetic failure'
);
await expect(
firstValueFrom(service.getAllPlaylists())
).resolves.toEqual([]);
expect(electron.dbMigrateAppPlaylists).toHaveBeenCalledTimes(2);
});
});
+22 -28
View File
@@ -72,6 +72,8 @@ type PlaylistStorageElectronApi = {
playlist: Playlist,
operationId?: string
) => Promise<unknown>;
dbMigrateAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
dbRecoverLegacyPlaylists?: () => Promise<void>;
dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
};
@@ -141,8 +143,12 @@ export class PlaylistsService {
if (!this.electronMigrationPromise) {
this.electronMigrationPromise = (async () => {
await this.migrateIndexedDbPlaylistsToSqlite();
await this.electronApi?.dbRecoverLegacyPlaylists?.();
await this.migrateStalkerPlaylistMetadataInSqlite();
})();
})().catch((error) => {
this.electronMigrationPromise = null;
throw error;
});
}
return this.electronMigrationPromise;
@@ -176,34 +182,22 @@ export class PlaylistsService {
}
private async migrateIndexedDbPlaylistsToSqlite(): Promise<void> {
try {
const electron = this.electronApi;
if (!electron) {
return;
}
const alreadyMigrated = await electron.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated === '1') {
return;
}
const indexedDbPlaylists = await firstValueFrom(
this.dbService.getAll<Playlist>(DbStores.Playlists)
);
if (indexedDbPlaylists.length > 0) {
await electron.dbUpsertAppPlaylists(indexedDbPlaylists);
await firstValueFrom(this.dbService.clear(DbStores.Playlists));
}
const electron = this.electronApi;
if (!electron) return;
if (
(await electron.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) ===
'1'
)
return;
const playlists = await firstValueFrom(
this.dbService.getAll<Playlist>(DbStores.Playlists)
);
if (playlists.length) {
// The worker commits rows and the receipt atomically. Keep the
// original IndexedDB as a recovery source, even after success.
await electron.dbMigrateAppPlaylists(playlists);
} else {
await electron.dbSetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG, '1');
} catch (error) {
console.error(
'Failed to migrate IndexedDB playlists to SQLite:',
error
);
}
}