fix(services): drop the migration receipt memo for M3U favorites

Skipping the receipt read let the dashboard ask the database for M3U
favorite channels before a just-toggled favorite was written: the write
waits in the per-playlist queue and the cross-context lock, and the
dashboard does not reload when the store's favorites are unchanged.
The extra round trip had been masking that race, and the Windows E2E
run hit it (epg.e2e.ts "dashboard live rails find a programme that only
another playlist's XMLTV carries"). The memo was off the first card's
path, so it bought nothing measurable for J1; restore the per-call read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 08:59:35 +02:00
1 parent 9599ed481c
commit e82d46c051
4 files changed
+9 -68

No files matched your search

+1 -1
View File
@@ -3,4 +3,4 @@ type: perf
area: workspace
---
On launch the workspace reads the source list once instead of twice before its first source card (journey J1, `renderer.ipcCallsToFirstCard`), and M3U favorites stop re-checking the finished playlist migration for every playlist.
On launch the workspace reads the source list once instead of twice before its first source card (journey J1, `renderer.ipcCallsToFirstCard`).
@@ -44,7 +44,6 @@ describe('PlaylistsService inventory reads', () => {
runtime: { supportsSqlite: true },
electronMigrationPromise: null,
pendingMetas: null,
migrated: false,
playlistWriteQueues: new Map(),
});
const settle = async (index: number, playlists: Playlist[]) => {
@@ -102,50 +101,4 @@ describe('PlaylistsService inventory reads', () => {
await expect(freshRead).resolves.toEqual([added]);
expect(electron.dbGetAppPlaylistMetas).toHaveBeenCalledTimes(2);
});
it('reads the SQLite migration receipt once for M3U favorites', async () => {
const { electron, service, settle } = setup();
const inventory = firstValueFrom(service.getAllPlaylists());
await settle(0, []);
await inventory;
const receiptReads = electron.dbGetAppState.mock.calls.length;
await firstValueFrom(service.getM3uFavoriteChannels('one'));
await firstValueFrom(service.getM3uFavoriteChannels('two'));
expect(electron.dbGetAppState).toHaveBeenCalledTimes(receiptReads);
expect(
electron.dbGetAppPlaylistFavoriteChannels
).toHaveBeenCalledTimes(2);
});
it.each([
['an empty legacy store', [], { success: true, count: 0 }, true],
['a committed migration', [source('legacy')], { success: true, count: 1 }, true],
['an uncommitted migration', [source('legacy')], { success: false, count: 0 }, false],
])(
'trusts the receipt written by %s in this session',
async (_case, legacy, result, confirmed) => {
const { electron, service, settle } = setup();
electron.dbGetAppState.mockImplementation(async (key: string) =>
key === 'm3u-playlists-indexeddb-to-sqlite-v1' ? null : '1'
);
Object.assign(electron, {
dbMigrateAppPlaylists: jest.fn(async () => result),
});
Object.assign(service, {
dbService: { getAll: jest.fn(() => of(legacy)) },
});
const inventory = firstValueFrom(service.getAllPlaylists());
await settle(0, []);
await inventory;
const receiptReads = electron.dbGetAppState.mock.calls.length;
await firstValueFrom(service.getM3uFavoriteChannels('one'));
expect(electron.dbGetAppState).toHaveBeenCalledTimes(
receiptReads + (confirmed ? 0 : 1)
);
}
);
});
@@ -61,7 +61,6 @@ describe('PlaylistsService', () => {
electronMigrationPromise: null,
indexedDbMigrationPromise: null,
pendingMetas: null,
migrated: false,
playlistWriteQueues: new Map(),
playlistDeleteCleanups: [],
});
+8 -19
View File
@@ -78,9 +78,7 @@ type PlaylistStorageElectronApi = {
playlist: Playlist,
operationId?: string
) => Promise<unknown>;
dbMigrateAppPlaylists: (
playlists: Playlist[]
) => Promise<{ success: boolean; count: number }>;
dbMigrateAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
dbRecoverLegacyPlaylists?: () => Promise<void>;
dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
};
@@ -122,8 +120,6 @@ export class PlaylistsService {
// XMLTV source reconciliation); both share one worker round trip. Only a
// pending read is shared, and every SQLite write detaches it.
private pendingMetas: Promise<Playlist[]> | null = null;
// The IndexedDB → SQLite receipt is written once and never cleared.
private migrated = false;
private indexedDbMigrationPromise: Promise<void> | null = null;
private readonly playlistWriteQueues = new Map<string, Promise<unknown>>();
@@ -203,21 +199,17 @@ export class PlaylistsService {
if (
(await electron.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) ===
'1'
) {
this.migrated = true;
)
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.
const result = await electron.dbMigrateAppPlaylists(playlists);
this.migrated = result?.success === true;
await electron.dbMigrateAppPlaylists(playlists);
} else {
await electron.dbSetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG, '1');
this.migrated = true;
}
}
@@ -1099,14 +1091,11 @@ export class PlaylistsService {
return from(
(async () => {
if (!this.migrated) {
const alreadyMigrated = await electron.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated !== '1') {
return null;
}
this.migrated = true;
const alreadyMigrated = await electron.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated !== '1') {
return null;
}
return getFavoriteChannels(playlistId);