diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index d1824a696..69cef5714 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -201,8 +201,9 @@ settings and guards read them in every build. `keepalive` flush when the page hides. Batches go out one after another (the page-hide flush waits for a batch still travelling), a failed write, including a refused `keepalive` one, stays queued for the next flush, and - the page-hide flush saves every unconfirmed write (queued or travelling) - under `iptvnator-server:unsent-writes:` for the next visit. + every unconfirmed write (queued or travelling) is saved in the tab's own + `sessionStorage` (`iptvnator-server:unsent-writes:`) and sent + first after a reload; tabs never clear each other's. Cache and saved writes belong to one server database (`iptvnator-server:cache-owner`); another one behind the same address starts clean. Also, @@ -212,9 +213,13 @@ settings and guards read them in every build. its headers counts as offline. Edits made before the first successful read are held as before → after and applied to the server's value once it arrives (`applyEdit`): a new device or a stale cache adds its favorite - instead of replacing the server's list. They are saved too - (`iptvnator-server:deferred-edits:`), and queued writes are - saved as soon as they are queued, so a re-read in another tab keeps them. A re-read + instead of replacing the server's list. They are saved in the tab too + (`iptvnator-server:deferred-edits:`). A shared registry in + `localStorage` (`iptvnator-server:unconfirmed:`, one record + per tab, ignored after ten minutes) lets a re-read in one tab keep the + cache values another tab is still sending. A value too big for + `localStorage` stays readable from memory, so callers and the diff + baseline agree. A re-read updates the cache; views already open show another device's changes on their next load (no live push). Start-up waits up to 3 s for the first read. @@ -246,7 +251,8 @@ settings and guards read them in every build. URL; older ones by a channel id that is random per import and is translated through the URL; nothing is dropped), and the legacy Xtream maps follow under the server id; each copy's bare legacy Xtream ids are typed from its own - snapshots first (`typeLegacyXtreamRefs`), then `combineUserData` unions + snapshots first (`typeLegacyXtreamRefs`; snapshots keep their bare key as + an alias), then `combineUserData` unions lists (playback positions by content type and Xtream id, renamed to the server playlist) and merges maps, the server's value winning on a conflict. Parental locks stay in the browser but move to the server diff --git a/libs/services/src/lib/server-storage/server-migration-xtream.spec.ts b/libs/services/src/lib/server-storage/server-migration-xtream.spec.ts index cca1d8040..b929afc06 100644 --- a/libs/services/src/lib/server-storage/server-migration-xtream.spec.ts +++ b/libs/services/src/lib/server-storage/server-migration-xtream.spec.ts @@ -16,6 +16,7 @@ describe('typeLegacyXtreamRefs', () => { 'xtream-favorites': ['live:42', 'movie:7', 99], 'xtream-recent-items': [{ id: 'live:42', viewedAt: 't' }], 'xtream-collection-items': { + '42': { xtream_id: 42, type: 'live', title: 'News' }, 'live:42': { xtream_id: 42, type: 'live', title: 'News' }, 'movie:7': { xtream_id: 7, type: 'movie' }, }, @@ -48,6 +49,8 @@ describe('typeLegacyXtreamRefs', () => { browser['xtream-collection-items'], ]) ).toEqual({ + // The server's own bare alias wins its key; both typed keys stay. + '42': { xtream_id: 42, type: 'movie' }, 'movie:42': { xtream_id: 42, type: 'movie' }, 'live:42': { xtream_id: 42, type: 'live' }, }); diff --git a/libs/services/src/lib/server-storage/server-migration-xtream.ts b/libs/services/src/lib/server-storage/server-migration-xtream.ts index 9087ac907..0ed64b230 100644 --- a/libs/services/src/lib/server-storage/server-migration-xtream.ts +++ b/libs/services/src/lib/server-storage/server-migration-xtream.ts @@ -10,7 +10,8 @@ const SNAPSHOTS = 'xtream-collection-items'; * repeats across live, movie and series. Before two copies are combined, * each one's bare ids become typed keys (`live:42`) using that copy's own * collection snapshots, the same evidence the PWA reads them with; ids - * without a snapshot stay as they are. + * without a snapshot stay as they are. Snapshots gain the typed key and + * keep the bare one. */ export function typeLegacyXtreamRefs(data: XtreamUserData): XtreamUserData { const snapshots = isRecord(data[SNAPSHOTS]) ? data[SNAPSHOTS] : {}; @@ -45,12 +46,16 @@ export function typeLegacyXtreamRefs(data: XtreamUserData): XtreamUserData { ); } if (isRecord(data[SNAPSHOTS])) { - const rekeyed: Record = {}; + // The bare key stays as an alias: a reference not yet rewritten (a + // failed write, an older client) still finds its snapshot, and a + // retry can type it again. + const aliased: Record = { ...snapshots }; for (const [key, snapshot] of Object.entries(snapshots)) { - rekeyed[String(/^\d+$/.test(key) ? typed(Number(key)) : key)] = - snapshot; + if (/^\d+$/.test(key)) { + aliased[String(typed(Number(key)))] = snapshot; + } } - result[SNAPSHOTS] = rekeyed; + result[SNAPSHOTS] = aliased; } return result; } diff --git a/libs/services/src/lib/server-storage/server-storage-session.ts b/libs/services/src/lib/server-storage/server-storage-session.ts index d8e3d6db0..645279984 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.ts @@ -12,7 +12,10 @@ import { SERVER_SIGN_IN_PATH, ServerStorageStatusService, } from '../server-storage-status.service'; -import { createBrowserUserDataStorage } from '../user-data-storage'; +import { + createBrowserSessionStorage, + createBrowserUserDataStorage, +} from '../user-data-storage'; import { ServerApiClient, type ServerApiError } from './server-api-client'; import { ServerPlaylistDocumentStore } from './server-playlist-document-store'; import { SERVER_STORAGE_CONFIG } from './server-storage.config'; @@ -43,7 +46,8 @@ export class ServerStorageSession { // Lazy: a browser that denies storage must not stop the app here. createBrowserUserDataStorage(), () => this.announceWriteFailure(), - this.config.instance.instanceId ?? undefined + this.config.instance.instanceId ?? undefined, + createBrowserSessionStorage() ); constructor() { diff --git a/libs/services/src/lib/server-storage/server-unsent-writes.spec.ts b/libs/services/src/lib/server-storage/server-unsent-writes.spec.ts new file mode 100644 index 000000000..e2a36206a --- /dev/null +++ b/libs/services/src/lib/server-storage/server-unsent-writes.spec.ts @@ -0,0 +1,40 @@ +import { + markUnconfirmed, + othersUnconfirmed, + UNCONFIRMED_TTL_MS, +} from './server-unsent-writes'; + +function memoryStorage() { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + removeItem: (key: string) => void values.delete(key), + }; +} + +describe('unconfirmed entries across tabs', () => { + it('lists other live tabs only and lets a tab clear just its record', () => { + const local = memoryStorage(); + markUnconfirmed(local, 'db', 'a', ['xtream-favorites|p1'], 1_000); + markUnconfirmed(local, 'db', 'b', ['xtream-favorites|p2'], 1_000); + + expect([...othersUnconfirmed(local, 'db', 'a', 2_000)]).toEqual([ + 'xtream-favorites|p2', + ]); + + markUnconfirmed(local, 'db', 'b', [], 3_000); + expect([...othersUnconfirmed(local, 'db', 'b', 3_000)]).toEqual([ + 'xtream-favorites|p1', + ]); + }); + + it('ignores the record of a tab that is gone', () => { + const local = memoryStorage(); + markUnconfirmed(local, 'db', 'a', ['xtream-favorites|p1'], 0); + + expect( + othersUnconfirmed(local, 'db', 'b', UNCONFIRMED_TTL_MS + 1).size + ).toBe(0); + }); +}); diff --git a/libs/services/src/lib/server-storage/server-unsent-writes.ts b/libs/services/src/lib/server-storage/server-unsent-writes.ts index b32df1fc5..7caadc98c 100644 --- a/libs/services/src/lib/server-storage/server-unsent-writes.ts +++ b/libs/services/src/lib/server-storage/server-unsent-writes.ts @@ -16,6 +16,16 @@ export const UNSENT_WRITES_KEY = 'iptvnator-server:unsent-writes'; export const CACHE_OWNER_KEY = 'iptvnator-server:cache-owner'; const writesKey = (instanceId: string) => `${UNSENT_WRITES_KEY}:${instanceId}`; +/** + * Which entries each open tab has not had confirmed yet, so a re-read in + * one tab keeps the cache values another tab is still sending. A record + * older than `UNCONFIRMED_TTL_MS` belongs to a tab that is gone. + */ +export const UNCONFIRMED_KEY = 'iptvnator-server:unconfirmed'; +export const UNCONFIRMED_TTL_MS = 10 * 60 * 1000; +const unconfirmedKey = (instanceId: string) => + `${UNCONFIRMED_KEY}:${instanceId}`; + /** Edits made before the first read of a visit, kept as before → after. */ export const DEFERRED_EDITS_KEY = 'iptvnator-server:deferred-edits'; const deferredKey = (instanceId: string) => @@ -36,6 +46,7 @@ export function claimCache(local: UserDataStorage, instanceId: string): void { local.removeItem(`iptvnator-server:${namespace}`); } if (owner) { + local.removeItem(unconfirmedKey(owner)); local.removeItem(writesKey(owner)); local.removeItem(deferredKey(owner)); } @@ -194,3 +205,69 @@ export function saveDeferredEdits( // Storage unavailable or full: the edits stay in memory only. } } + +type UnconfirmedRegistry = Record; + +function readRegistry( + local: UserDataStorage, + instanceId: string +): UnconfirmedRegistry { + try { + const parsed: unknown = JSON.parse( + local.getItem(unconfirmedKey(instanceId)) ?? '{}' + ); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as UnconfirmedRegistry) + : {}; + } catch { + return {}; + } +} + +/** Replaces only this tab's record. */ +export function markUnconfirmed( + local: UserDataStorage, + instanceId: string, + tabId: string, + keys: readonly string[], + now = Date.now() +): void { + const registry = readRegistry(local, instanceId); + if (keys.length === 0) { + if (!(tabId in registry)) { + return; + } + delete registry[tabId]; + } else { + registry[tabId] = { at: now, keys: [...keys] }; + } + try { + local.setItem(unconfirmedKey(instanceId), JSON.stringify(registry)); + } catch { + // Storage full: re-reads in other tabs lose this protection only. + } +} + +/** Entry keys other live tabs have not had confirmed yet. */ +export function othersUnconfirmed( + local: UserDataStorage, + instanceId: string, + tabId: string, + now = Date.now() +): Set { + const keys = new Set(); + for (const [owner, record] of Object.entries( + readRegistry(local, instanceId) + )) { + if ( + owner !== tabId && + Array.isArray(record?.keys) && + now - Number(record.at) < UNCONFIRMED_TTL_MS + ) { + for (const key of record.keys) { + keys.add(String(key)); + } + } + } + return keys; +} diff --git a/libs/services/src/lib/server-storage/server-user-data-storage.spec.ts b/libs/services/src/lib/server-storage/server-user-data-storage.spec.ts index 2f0d2b98e..cece20be0 100644 --- a/libs/services/src/lib/server-storage/server-user-data-storage.spec.ts +++ b/libs/services/src/lib/server-storage/server-user-data-storage.spec.ts @@ -408,7 +408,7 @@ describe('ServerUserDataStorage', () => { expect(api.sendJson).not.toHaveBeenCalled(); }); - it('keeps cache and baseline together when the cache is full', async () => { + it('keeps a value that does not fit the cache readable and consistent', async () => { const { api, local, storage } = setup(); await storage.hydrate(); // The server now has a second playlist the cache cannot take. @@ -425,20 +425,25 @@ describe('ServerUserDataStorage', () => { setItem(key, value); }; await storage.hydrate(); - local.setItem = setItem; + // The app reads, edits and writes back, as PwaCollectionStorage does. + const current = JSON.parse(storage.getItem('xtream-favorites') ?? ''); + expect(current).toEqual({ p1: ['live:1'], p2: ['live:2'] }); storage.setItem( 'xtream-favorites', - JSON.stringify({ p1: ['live:1', 'live:3'] }) + JSON.stringify({ ...current, p1: ['live:1', 'live:3'] }) ); await storage.flush(); + local.setItem = setItem; expect(api.request).not.toHaveBeenCalled(); - expect(api.sendJson).toHaveBeenCalledWith( - 'PUT', - '/user-state/xtream-favorites/p1', - { value: ['live:1', 'live:3'] } - ); + expect(api.sendJson.mock.calls).toEqual([ + [ + 'PUT', + '/user-state/xtream-favorites/p1', + { value: ['live:1', 'live:3'] }, + ], + ]); }); it('keeps unconfirmed writes across a reload and sends them first', async () => { @@ -623,40 +628,70 @@ describe('ServerUserDataStorage', () => { }); it("does not replace another tab's unconfirmed edit on a re-read", async () => { - // A server that keeps what it is sent. - const state: Record> = { - 'xtream-favorites': { p1: ['live:1'] }, + const shared = memoryStorage(); + const api = { + getJson: jest.fn().mockResolvedValue({ + entries: { 'xtream-favorites': { p1: ['live:1'] } }, + }), + sendJson: jest.fn().mockResolvedValue(undefined), + request: jest.fn().mockResolvedValue(undefined), + sendKeepalive: jest.fn().mockResolvedValue(true), }; - const tabA = setup(); - tabA.api.getJson.mockImplementation(async () => ({ - entries: structuredClone(state), - })); - tabA.api.sendJson.mockImplementation( - async (_method: string, path: string, body: { value: unknown }) => { - const [, , namespace, playlistId] = path.split('/'); - state[namespace] = { - ...state[namespace], - [playlistId]: body.value, - }; - } - ); - await tabA.storage.hydrate(); - tabA.storage.setItem( + const tab = () => + new ServerUserDataStorage( + api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + const tabA = tab(); + const tabB = tab(); + await tabA.hydrate(); + await tabB.hydrate(); + + // Tab A's edit is queued, not confirmed; tab B re-reads. + tabA.setItem( 'xtream-favorites', JSON.stringify({ p1: ['live:1', 'live:7'] }) ); - - // Tab A's write is queued, not sent yet; tab B starts and re-reads. - const tabB = new ServerUserDataStorage( - tabA.api as never, - tabA.local, - jest.fn() - ); await tabB.hydrate(); expect(JSON.parse(tabB.getItem('xtream-favorites') ?? '')).toEqual({ p1: ['live:1', 'live:7'], }); - expect(state['xtream-favorites']['p1']).toEqual(['live:1', 'live:7']); + // Tab B never takes over tab A's saved writes. + expect(api.sendJson).not.toHaveBeenCalled(); + }); + + it('keeps server favorites through a cache-full first read and a later edit', async () => { + const { api, local, storage } = setup(); + local.setItem('iptvnator-server:xtream-favorites', '{"p1":["A"]}'); + storage.setItem('xtream-favorites', '{"p1":["A","B"]}'); + api.getJson.mockResolvedValueOnce({ + entries: { 'xtream-favorites': { p1: ['S'] } }, + }); + const setItem = local.setItem; + local.setItem = (key: string, value: string) => { + if (key === 'iptvnator-server:xtream-favorites') { + throw new DOMException('full', 'QuotaExceededError'); + } + setItem(key, value); + }; + await storage.hydrate(); + + const current = JSON.parse(storage.getItem('xtream-favorites') ?? ''); + storage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: [...current.p1, 'C'] }) + ); + await storage.flush(); + local.setItem = setItem; + + expect(api.sendJson).toHaveBeenLastCalledWith( + 'PUT', + '/user-state/xtream-favorites/p1', + { value: ['S', 'B', 'C'] } + ); }); }); 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 5c88713af..92305baf3 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 @@ -16,7 +16,9 @@ import { import { type DeferredEdit, refreshedNamespace } from './server-user-data-edit'; import { claimCache, + markUnconfirmed, newestWrites, + othersUnconfirmed, readDeferredEdits, readUnsentWrites, saveDeferredEdits, @@ -58,6 +60,10 @@ export class ServerUserDataStorage implements UserDataStorage { */ private readonly deferred = new Map(); private hydrated = false; + /** This tab, in the shared registry of unconfirmed entries. */ + private readonly tabId = Math.random().toString(36).slice(2); + /** Cache values that did not fit into browser storage. */ + private readonly overflow = new Map(); /** Re-reads run one at a time, so an older one never lands last. */ private hydration: Promise = Promise.resolve(); /** Edit counter per `namespace|playlistId`, for hydrate to keep them. */ @@ -69,16 +75,22 @@ export class ServerUserDataStorage implements UserDataStorage { private readonly local: UserDataStorage, private readonly onWriteFailure: (error: unknown) => void, /** Scopes the cache and saved writes to one server database. */ - private readonly instanceId = 'default' + private readonly instanceId = 'default', + /** + * Where this tab keeps what the server has not confirmed: its own + * (`sessionStorage` in the app), so tabs never clear each other's, + * and a reload of the tab finds it again. + */ + private readonly tabStore: UserDataStorage = local ) { claimCache(local, instanceId); // Writes a previous visit could not confirm go out with the first // flush, and the first read keeps them. - for (const write of readUnsentWrites(local, instanceId)) { + for (const write of readUnsentWrites(tabStore, instanceId)) { this.queue(write.namespace, write.playlistId, write.value); } this.unsentSaved = this.pending.size > 0; - for (const edit of readDeferredEdits(local, instanceId)) { + for (const edit of readDeferredEdits(tabStore, instanceId)) { this.deferred.set(`${edit.namespace}|${edit.playlistId}`, edit); } } @@ -94,7 +106,7 @@ export class ServerUserDataStorage implements UserDataStorage { getItem(key: string): string | null { return this.isSynced(key) - ? this.local.getItem(SERVER_CACHE_PREFIX + key) + ? this.readCache(key) : this.local.getItem(key); } @@ -104,7 +116,7 @@ export class ServerUserDataStorage implements UserDataStorage { return; } const before = this.baseline(key); - this.local.setItem(SERVER_CACHE_PREFIX + key, value); + this.writeCache(key, value); this.queueChanges(key, parseMap(value), before); } @@ -114,7 +126,7 @@ export class ServerUserDataStorage implements UserDataStorage { return; } const before = this.baseline(key); - this.local.removeItem(SERVER_CACHE_PREFIX + key); + this.writeCache(key, null); this.queueChanges(key, {}, before); } @@ -148,8 +160,12 @@ export class ServerUserDataStorage implements UserDataStorage { } } // Another tab's unconfirmed writes are in the shared cache too. - for (const write of readUnsentWrites(this.local, this.instanceId)) { - unsaved.add(`${write.namespace}|${write.playlistId}`); + for (const key of othersUnconfirmed( + this.local, + this.instanceId, + this.tabId + )) { + unsaved.add(key); } const deferred = [...this.deferred.values()]; const refreshed = new Map>(); @@ -163,23 +179,14 @@ export class ServerUserDataStorage implements UserDataStorage { } const value = refreshedNamespace( entries[namespace] ?? {}, - parseMap( - this.local.getItem(SERVER_CACHE_PREFIX + namespace) ?? '{}' - ), + parseMap(this.readCache(namespace) ?? '{}'), keepCached, deferred.filter((edit) => edit.namespace === namespace) ); refreshed.set(namespace, value); - try { - this.local.setItem( - SERVER_CACHE_PREFIX + namespace, - JSON.stringify(value) - ); - } catch { - // Storage full: keep the old cache and the baseline it was - // diffed against together, or an edit would read as deletes. - continue; - } + // A value too big for browser storage stays readable in memory, + // so callers and the diff baseline see the same map. + this.writeCache(namespace, JSON.stringify(value)); this.synced.set(namespace, value); } for (const [key, edit] of this.edits) { @@ -202,7 +209,7 @@ export class ServerUserDataStorage implements UserDataStorage { refreshed.get(edit.namespace)?.[edit.playlistId] ); } - saveDeferredEdits(this.local, this.instanceId, []); + saveDeferredEdits(this.tabStore, this.instanceId, []); this.scheduleFlush(); } } @@ -266,6 +273,30 @@ export class ServerUserDataStorage implements UserDataStorage { ); } + private readCache(namespace: ServerUserStateNamespace): string | null { + return ( + this.overflow.get(namespace) ?? + this.local.getItem(SERVER_CACHE_PREFIX + namespace) + ); + } + + /** `null` empties the entry. Browser storage first, memory if full. */ + private writeCache( + namespace: ServerUserStateNamespace, + value: string | null + ): void { + try { + if (value === null) { + this.local.removeItem(SERVER_CACHE_PREFIX + namespace); + } else { + this.local.setItem(SERVER_CACHE_PREFIX + namespace, value); + } + this.overflow.delete(namespace); + } catch { + this.overflow.set(namespace, value ?? '{}'); + } + } + private isSynced(key: string): key is ServerUserStateNamespace { return isServerUserStateNamespace(key); } @@ -279,9 +310,7 @@ export class ServerUserDataStorage implements UserDataStorage { ): Record { return ( this.synced.get(namespace) ?? - parseMap( - this.local.getItem(SERVER_CACHE_PREFIX + namespace) ?? '{}' - ) + parseMap(this.readCache(namespace) ?? '{}') ); } @@ -304,9 +333,10 @@ export class ServerUserDataStorage implements UserDataStorage { before: earlier ? earlier.before : previous[playlistId], after: value, }); - saveDeferredEdits(this.local, this.instanceId, [ + saveDeferredEdits(this.tabStore, this.instanceId, [ ...this.deferred.values(), ]); + this.publishUnconfirmed(); }; for (const [playlistId, value] of Object.entries(next)) { if ( @@ -424,7 +454,16 @@ export class ServerUserDataStorage implements UserDataStorage { if (writes.length === 0 && !this.unsentSaved) { return; } - saveUnsentWrites(this.local, this.instanceId, writes); + saveUnsentWrites(this.tabStore, this.instanceId, writes); this.unsentSaved = writes.length > 0; + this.publishUnconfirmed(); + } + + private publishUnconfirmed(): void { + markUnconfirmed(this.local, this.instanceId, this.tabId, [ + ...this.pending.keys(), + ...this.inFlight.keys(), + ...this.deferred.keys(), + ]); } } diff --git a/libs/services/src/lib/user-data-storage.ts b/libs/services/src/lib/user-data-storage.ts index 78552b563..135b165fc 100644 --- a/libs/services/src/lib/user-data-storage.ts +++ b/libs/services/src/lib/user-data-storage.ts @@ -20,6 +20,15 @@ export function createBrowserUserDataStorage(): UserDataStorage { }; } +/** The same lazy adapter over `sessionStorage`: one per tab, kept on reload. */ +export function createBrowserSessionStorage(): UserDataStorage { + return { + getItem: (key) => globalThis.sessionStorage.getItem(key), + setItem: (key, value) => globalThis.sessionStorage.setItem(key, value), + removeItem: (key) => globalThis.sessionStorage.removeItem(key), + }; +} + /** * Browser persistence for PWA user data such as Xtream favorites, recently * viewed items and playback positions. Defaults to `localStorage`; the