diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index d415f9b48..1b4408f78 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -232,7 +232,8 @@ settings and guards read them in every build. only a closed, stale or missing record is taken over: a duplicated tab, which copies `sessionStorage`, leaves the running tab its writes. A page load that still looked live (or whose close flag storage refused) is - checked again every 30 s and taken over once it does not; the writes of a tab closed before it + checked again every 30 s and, once it does not, adopted like a closed + tab's: its edits go ahead of this page's newer ones; the writes of a tab closed before it could send them are queued, ahead of its own, by the next re-read in another tab once that tab's record has gone stale; the result replays the closed tab's edits first and this tab's newer ones last. Tabs never clear each other's. diff --git a/libs/services/src/lib/server-storage/server-user-data-cache.ts b/libs/services/src/lib/server-storage/server-user-data-cache.ts index 8e0ee00b3..422f74bb1 100644 --- a/libs/services/src/lib/server-storage/server-user-data-cache.ts +++ b/libs/services/src/lib/server-storage/server-user-data-cache.ts @@ -1,4 +1,7 @@ -import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces'; +import { + SERVER_USER_STATE_NAMESPACES, + type ServerUserStateNamespace, +} from '@iptvnator/shared/interfaces'; import type { UserDataStorage } from '../user-data-storage'; import { parseMap } from './server-user-data-entries'; @@ -45,6 +48,19 @@ export class UserDataCache { return parseMap(this.read(namespace) ?? '{}'); } + /** After a delete: one playlist's entries, without an id all of them. */ + forget(playlistId?: string): void { + for (const namespace of SERVER_USER_STATE_NAMESPACES) { + const map = this.map(namespace); + if (playlistId === undefined) { + this.write(namespace, null); + } else if (playlistId in map) { + delete map[playlistId]; + this.write(namespace, JSON.stringify(map)); + } + } + } + /** `undefined` removes the playlist's entry. */ setEntry( namespace: ServerUserStateNamespace, diff --git a/libs/services/src/lib/server-storage/server-user-data-storage.ts b/libs/services/src/lib/server-storage/server-user-data-storage.ts index 266fe2f3c..4833acdf6 100644 --- a/libs/services/src/lib/server-storage/server-user-data-storage.ts +++ b/libs/services/src/lib/server-storage/server-user-data-storage.ts @@ -101,11 +101,15 @@ export class ServerUserDataStorage implements UserDataStorage { writeFailed: onWriteFailure, }); claimCache(local, instanceId); + const previous = swapPage(tabStore, instanceId, this.tabId); // A duplicated tab copies the page load of a tab that still runs; // so does a reload whose close flag could not be saved: `touch` - // takes it over once it no longer looks live. - this.awaited = swapPage(tabStore, instanceId, this.tabId); - this.touch(); + // adopts it once it no longer looks live. + if (previous && pageIsLive(local, instanceId, previous)) { + this.awaited = previous; + } else if (previous) { + this.takeOver(previous); + } } /** The page unloads: a reload of this tab may take its writes over. */ @@ -155,40 +159,46 @@ export class ServerUserDataStorage implements UserDataStorage { this.instanceId, this.tabId ).filter((page) => !this.adopted.has(page))) { - const edits = [ - ...readDeferredEdits(this.local, this.instanceId, page), - ...readUnsentWrites(this.local, this.instanceId, page), - ]; - const foreign = othersUnconfirmed( - this.local, - this.instanceId, - this.tabId - ); - for (const { namespace, playlistId, steps } of edits) { - // Queued even when the cache shows it already. - const value = adoptEdits( - this.writes, - namespace, - playlistId, - this.cache.map(namespace)[playlistId], - steps, - foreign.has(`${namespace}|${playlistId}`) - ); - this.cache.setEntry(namespace, playlistId, value); - } - this.adopted.add(page); - // The closed tab's copy goes only once this tab saved its own. - this.release(page, this.saveUnsent()); - this.writes.scheduleFlush(); + this.adoptPage(page); } } + /** Its edits go ahead of this tab's own, which may be newer. */ + private adoptPage(page: string): void { + const edits = [ + ...readDeferredEdits(this.local, this.instanceId, page), + ...readUnsentWrites(this.local, this.instanceId, page), + ]; + const foreign = othersUnconfirmed( + this.local, + this.instanceId, + this.tabId + ); + for (const { namespace, playlistId, steps } of edits) { + // Queued even when the cache shows it already. + const value = adoptEdits( + this.writes, + namespace, + playlistId, + this.cache.map(namespace)[playlistId], + steps, + foreign.has(`${namespace}|${playlistId}`) + ); + this.cache.setEntry(namespace, playlistId, value); + } + this.adopted.add(page); + // The other page's copy goes only once this tab saved its own. + this.release(page, this.saveUnsent()); + this.writes.scheduleFlush(); + } + /** Tells other tabs this one is alive and still sending its writes. */ touch(): void { const page = this.awaited; if (page && !pageIsLive(this.local, this.instanceId, page)) { + // This page may have read and edited since: adopt, not restore. this.awaited = null; - this.takeOver(page); + this.adoptPage(page); } if (this.hasUnsentWrites()) { this.publishUnconfirmed(); @@ -292,31 +302,14 @@ export class ServerUserDataStorage implements UserDataStorage { try { await this.writes.whenSent(); await remove(); - this.forget(matches, playlistId); - } finally { - release(); - } - } - - private forget( - matches: (key: string) => boolean, - playlistId: string | undefined - ): void { - for (const key of [...this.deferred.keys()]) { - if (matches(key)) { + for (const key of [...this.deferred.keys()].filter(matches)) { this.deferred.delete(key); } - } - this.saveDeferred(); - this.writes.drop(matches, playlistId); - for (const namespace of SERVER_USER_STATE_NAMESPACES) { - const map = this.cache.map(namespace); - if (playlistId === undefined) { - this.cache.write(namespace, null); - } else if (playlistId in map) { - delete map[playlistId]; - this.cache.write(namespace, JSON.stringify(map)); - } + this.saveDeferred(); + this.writes.drop(matches, playlistId); + this.cache.forget(playlistId); + } finally { + release(); } } diff --git a/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts index 681e9509b..228e6b9e5 100644 --- a/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts +++ b/libs/services/src/lib/server-storage/server-user-data-sync.spec.ts @@ -1067,4 +1067,120 @@ describe('ServerUserDataStorage with other devices', () => { expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); }); + + describe('a reload whose close flag was lost', () => { + const at = (seconds: number) => [ + { + contentType: 'vod', + contentXtreamId: 1, + positionSeconds: seconds, + }, + ]; + const offline = () => + Promise.reject(new ServerApiError(0, 'network', 'offline')); + + /** The last page load saves its edit, then unloads on a full disk. */ + async function lastPageLoad( + server: ReturnType, + local: ReturnType, + tabStore: ReturnType, + edit: (page: ServerUserDataStorage) => Promise + ) { + const page = new ServerUserDataStorage( + { ...server.api, sendJson: jest.fn(offline) } as never, + local, + jest.fn(), + 'default', + tabStore + ); + await edit(page); + const setItem = local.setItem; + local.setItem = () => { + throw new DOMException('full', 'QuotaExceededError'); + }; + page.close(); + local.setItem = setItem; + } + + it('keeps the newer progress of the page that adopts it later', async () => { + const server = fakeServer({ + 'xtream-playback-positions': { p1: at(10) }, + }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + await lastPageLoad(server, local, tabStore, async (page) => { + await page.hydrate(); + page.setItem( + 'xtream-playback-positions', + JSON.stringify({ p1: at(20) }) + ); + await page.flush(); + }); + let online = false; + const send = server.api.sendJson.getMockImplementation(); + if (!send) { + throw new Error('The fake server answers sendJson'); + } + const reloaded = new ServerUserDataStorage( + { + ...server.api, + sendJson: jest.fn((...args: Parameters) => + online ? send(...args) : offline() + ), + } as never, + local, + jest.fn(), + 'default', + tabStore + ); + await reloaded.hydrate(); + reloaded.setItem( + 'xtream-playback-positions', + JSON.stringify({ p1: at(30) }) + ); + await reloaded.flush(); + + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + reloaded.touch(); + online = true; + await reloaded.flush(); + await reloaded.flush(); + clock.mockRestore(); + + expect(server.state['xtream-playback-positions']['p1']).toEqual( + at(30) + ); + }); + + it('sends its early edits after this page has read', async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + await lastPageLoad(server, local, tabStore, async (page) => { + // Never read: the edit stays an early one. + page.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['S', 'X'] }) + ); + }); + const reloaded = new ServerUserDataStorage( + server.api as never, + local, + jest.fn(), + 'default', + tabStore + ); + await reloaded.hydrate(); + + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + reloaded.touch(); + await reloaded.flush(); + await reloaded.flush(); + clock.mockRestore(); + + expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); + }); + }); });