From e82d46c0510d08ad1b1f2bab4f5c1b88cade8167 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 22:40:37 +0200 Subject: [PATCH] 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 --- .changes/workspace-startup-first-card-ipc.md | 2 +- .../playlists.service.inventory-read.spec.ts | 47 ------------------- .../src/lib/playlists.service.spec.ts | 1 - libs/services/src/lib/playlists.service.ts | 27 ++++------- 4 files changed, 9 insertions(+), 68 deletions(-) diff --git a/.changes/workspace-startup-first-card-ipc.md b/.changes/workspace-startup-first-card-ipc.md index fc6c7e758..7df875c2c 100644 --- a/.changes/workspace-startup-first-card-ipc.md +++ b/.changes/workspace-startup-first-card-ipc.md @@ -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`). diff --git a/libs/services/src/lib/playlists.service.inventory-read.spec.ts b/libs/services/src/lib/playlists.service.inventory-read.spec.ts index 127984a8a..800171f83 100644 --- a/libs/services/src/lib/playlists.service.inventory-read.spec.ts +++ b/libs/services/src/lib/playlists.service.inventory-read.spec.ts @@ -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) - ); - } - ); }); diff --git a/libs/services/src/lib/playlists.service.spec.ts b/libs/services/src/lib/playlists.service.spec.ts index 172e2ef87..6b5332a2a 100644 --- a/libs/services/src/lib/playlists.service.spec.ts +++ b/libs/services/src/lib/playlists.service.spec.ts @@ -61,7 +61,6 @@ describe('PlaylistsService', () => { electronMigrationPromise: null, indexedDbMigrationPromise: null, pendingMetas: null, - migrated: false, playlistWriteQueues: new Map(), playlistDeleteCleanups: [], }); diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 2cc219fa3..143d7a798 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -78,9 +78,7 @@ type PlaylistStorageElectronApi = { playlist: Playlist, operationId?: string ) => Promise; - dbMigrateAppPlaylists: ( - playlists: Playlist[] - ) => Promise<{ success: boolean; count: number }>; + dbMigrateAppPlaylists: (playlists: Playlist[]) => Promise; dbRecoverLegacyPlaylists?: () => Promise; dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise; }; @@ -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 | null = null; - // The IndexedDB → SQLite receipt is written once and never cleared. - private migrated = false; private indexedDbMigrationPromise: Promise | null = null; private readonly playlistWriteQueues = new Map>(); @@ -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(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);