diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index a51b01c55..8cbd8f4f9 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -203,7 +203,9 @@ settings and guards read them in every build. the four synced namespaces are cached under the `iptvnator-server:` key prefix (the legacy unprefixed keys are never written in server mode) and each change sends only the playlist entries that differ, debounced, with a - `keepalive` flush when the page hides. Batches go out one after another + `keepalive` flush when the page hides. A change is compared with the map + the caller read: when the cache changed since (another tab, a re-read), + only the entries the caller changed are applied to it. 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 every unconfirmed write (queued or travelling) is saved in `localStorage` 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 new file mode 100644 index 000000000..8e0ee00b3 --- /dev/null +++ b/libs/services/src/lib/server-storage/server-user-data-cache.ts @@ -0,0 +1,62 @@ +import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces'; +import type { UserDataStorage } from '../user-data-storage'; +import { parseMap } from './server-user-data-entries'; + +/** Server-mode cache keys; the legacy unprefixed keys are never touched. */ +export const SERVER_CACHE_PREFIX = 'iptvnator-server:'; + +/** + * The synced namespaces as this browser shows them: in browser storage, + * shared by the tabs, or in memory when it is full or refuses access, so + * callers and the diff baseline always read the same map. + */ +export class UserDataCache { + private readonly overflow = new Map(); + + constructor(private readonly local: UserDataStorage) {} + + read(namespace: ServerUserStateNamespace): string | null { + const kept = this.overflow.get(namespace); + if (kept !== undefined) { + return kept; + } + try { + return this.local.getItem(SERVER_CACHE_PREFIX + namespace); + } catch { + return null; + } + } + + /** `null` empties the namespace. */ + write(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 ?? '{}'); + } + } + + map(namespace: ServerUserStateNamespace): Record { + return parseMap(this.read(namespace) ?? '{}'); + } + + /** `undefined` removes the playlist's entry. */ + setEntry( + namespace: ServerUserStateNamespace, + playlistId: string, + value: unknown + ): void { + const map = this.map(namespace); + if (value === undefined) { + delete map[playlistId]; + } else { + map[playlistId] = value; + } + this.write(namespace, JSON.stringify(map)); + } +} 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 958993169..89fbe8567 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 @@ -12,7 +12,9 @@ import { splitEditKey, UNKNOWN_TAG, } from './server-user-data-entries'; +import { UserDataCache } from './server-user-data-cache'; import { + applyEdit, type DeferredEdit, refreshedNamespace, replaySteps, @@ -31,8 +33,7 @@ import { swapPage, } from './server-unsent-writes'; -/** Server-mode cache keys; the legacy unprefixed keys are never touched. */ -export const SERVER_CACHE_PREFIX = 'iptvnator-server:'; +export { SERVER_CACHE_PREFIX } from './server-user-data-cache'; type ServerApi = Pick< ServerApiClient, @@ -59,8 +60,16 @@ export class ServerUserDataStorage implements UserDataStorage { private hydrated = false; /** This page load: names its saved writes and its registry record. */ private readonly tabId = Math.random().toString(36).slice(2); - /** Cache values that did not fit into browser storage. */ - private readonly overflow = new Map(); + private readonly cache: UserDataCache; + /** + * What `getItem` last handed out (or a write left) per namespace: the + * map a caller edits, so its write is compared with that, not with + * changes another tab or a re-read made to the cache since. + */ + private readonly handedOut = new Map< + ServerUserStateNamespace, + string | null + >(); /** Re-reads run one at a time, so an older one never lands last. */ private hydration: Promise = Promise.resolve(); @@ -76,10 +85,11 @@ export class ServerUserDataStorage implements UserDataStorage { */ tabStore: UserDataStorage = local ) { + this.cache = new UserDataCache(local); this.writes = new UserDataWriteQueue(api, { changed: () => this.saveUnsent(), show: (namespace, playlistId, value) => - this.showEntry(namespace, playlistId, value), + this.cache.setEntry(namespace, playlistId, value), writeFailed: onWriteFailure, }); claimCache(local, instanceId); @@ -142,10 +152,10 @@ export class ServerUserDataStorage implements UserDataStorage { ); for (const { namespace, playlistId, steps } of edits) { const value = replaySteps( - this.baseline(namespace)[playlistId], + this.cache.map(namespace)[playlistId], steps ); - this.showEntry(namespace, playlistId, value); + this.cache.setEntry(namespace, playlistId, value); // Queued even when the cache shows it already. this.writes.adopt( namespace, @@ -174,29 +184,52 @@ export class ServerUserDataStorage implements UserDataStorage { } getItem(key: string): string | null { - return this.isSynced(key) - ? this.readCache(key) - : this.local.getItem(key); + if (!this.isSynced(key)) { + return this.local.getItem(key); + } + const value = this.cache.read(key); + this.handedOut.set(key, value); + return value; } setItem(key: string, value: string): void { - if (!this.isSynced(key)) { + if (this.isSynced(key)) { + this.write(key, value); + } else { this.local.setItem(key, value); - return; } - const before = this.baseline(key); - this.writeCache(key, value); - this.queueChanges(key, parseMap(value), before); } removeItem(key: string): void { - if (!this.isSynced(key)) { + if (this.isSynced(key)) { + this.write(key, null); + } else { this.local.removeItem(key); - return; } - const before = this.baseline(key); - this.writeCache(key, null); - this.queueChanges(key, {}, before); + } + + /** + * A caller's new map. When the cache changed since the caller read it + * (another tab, a re-read), only the entries it changed are applied to + * the cache as it is now; the rest stays. + */ + private write( + namespace: ServerUserStateNamespace, + value: string | null + ): void { + const current = this.cache.read(namespace); + const before = parseMap(current ?? '{}'); + const read = this.handedOut.has(namespace) + ? (this.handedOut.get(namespace) ?? null) + : current; + let next = parseMap(value ?? '{}'); + if (read !== current) { + next = mergeEdits(before, parseMap(read ?? '{}'), next); + value = JSON.stringify(next); + } + this.cache.write(namespace, value); + this.handedOut.set(namespace, value); + this.queueChanges(namespace, next, before); } /** @@ -258,12 +291,12 @@ export class ServerUserDataStorage implements UserDataStorage { this.saveDeferred(); this.writes.drop(matches, playlistId); for (const namespace of SERVER_USER_STATE_NAMESPACES) { - const map = this.baseline(namespace); + const map = this.cache.map(namespace); if (playlistId === undefined) { - this.writeCache(namespace, null); + this.cache.write(namespace, null); } else if (playlistId in map) { delete map[playlistId]; - this.writeCache(namespace, JSON.stringify(map)); + this.cache.write(namespace, JSON.stringify(map)); } } } @@ -295,14 +328,14 @@ export class ServerUserDataStorage implements UserDataStorage { } const value = refreshedNamespace( entries[namespace] ?? {}, - this.baseline(namespace), + this.cache.map(namespace), keepCached, deferred.filter((edit) => edit.namespace === namespace) ); refreshed.set(namespace, value); // 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.cache.write(namespace, JSON.stringify(value)); if (tags) { // A kept value is not what the server's tag describes: its // next write must conflict and be replayed on the server's. @@ -332,66 +365,10 @@ export class ServerUserDataStorage implements UserDataStorage { this.adoptClosedTabs(); } - /** Memory first; browser storage that refuses access reads as empty. */ - private readCache(namespace: ServerUserStateNamespace): string | null { - const kept = this.overflow.get(namespace); - if (kept !== undefined) { - return kept; - } - try { - return this.local.getItem(SERVER_CACHE_PREFIX + namespace); - } catch { - return null; - } - } - - /** `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 ?? '{}'); - } - } - - /** A conflict replay settled what the entry holds. */ - private showEntry( - namespace: ServerUserStateNamespace, - playlistId: string, - value: unknown - ): void { - const map = this.baseline(namespace); - if (value === undefined) { - delete map[playlistId]; - } else { - map[playlistId] = value; - } - this.writeCache(namespace, JSON.stringify(map)); - } - private isSynced(key: string): key is ServerUserStateNamespace { return isServerUserStateNamespace(key); } - /** - * What a write is compared against: the map the caller read, so an edit - * sends only its own entries, never another tab's changes in the shared - * cache. - */ - private baseline( - namespace: ServerUserStateNamespace - ): Record { - return parseMap(this.readCache(namespace) ?? '{}'); - } - private queueChanges( namespace: ServerUserStateNamespace, next: Record, @@ -465,3 +442,23 @@ export class ServerUserDataStorage implements UserDataStorage { ]); } } + +/** The entries `next` changed against `read`, applied to `current`. */ +function mergeEdits( + current: Record, + read: Record, + next: Record +): Record { + const merged = { ...current }; + for (const id of new Set([...Object.keys(read), ...Object.keys(next)])) { + if (JSON.stringify(read[id]) !== JSON.stringify(next[id])) { + const value = applyEdit(current[id], read[id], next[id]); + if (value === undefined) { + delete merged[id]; + } else { + merged[id] = value; + } + } + } + return merged; +} 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 409ed1822..c54e62d65 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 @@ -678,4 +678,43 @@ describe('ServerUserDataStorage with other devices', () => { [...(server.state['xtream-favorites']['p1'] as string[])].sort() ).toEqual(['S', 'X', 'Y']); }); + + it('applies only the edits of a caller that read before another tab saved', async () => { + const server = fakeServer({ + 'xtream-favorites': { p1: ['a'], p2: ['b'] }, + }); + const shared = memoryStorage(); + const tab = () => + new ServerUserDataStorage( + server.api as never, + shared, + jest.fn(), + 'default', + memoryStorage() + ); + const tabA = tab(); + const tabB = tab(); + await tabA.hydrate(); + await tabB.hydrate(); + + const readByA = favorites(tabA); + tabB.setItem( + 'xtream-favorites', + JSON.stringify({ ...favorites(tabB), p2: ['b', 'B2'] }) + ); + await tabB.flush(); + // Tab A writes back the map it read before tab B saved. + tabA.setItem( + 'xtream-favorites', + JSON.stringify({ ...readByA, p1: ['a', 'A1'] }) + ); + await tabA.flush(); + await tabA.flush(); + + expect(server.state['xtream-favorites']).toEqual({ + p1: ['a', 'A1'], + p2: ['b', 'B2'], + }); + expect(favorites(tabA)).toEqual({ p1: ['a', 'A1'], p2: ['b', 'B2'] }); + }); });