diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index e37857417..d1824a696 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -212,7 +212,9 @@ 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. A re-read + 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 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. 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 2fca5db3f..cca1d8040 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 @@ -52,4 +52,15 @@ describe('typeLegacyXtreamRefs', () => { 'live:42': { xtream_id: 42, type: 'live' }, }); }); + + it('types numeric-string ids too', () => { + expect( + typeLegacyXtreamRefs({ + 'xtream-favorites': ['42'], + 'xtream-collection-items': { + '42': { xtream_id: 42, type: 'movie' }, + }, + })['xtream-favorites'] + ).toEqual(['movie:42']); + }); }); 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 6c040301a..9087ac907 100644 --- a/libs/services/src/lib/server-storage/server-migration-xtream.ts +++ b/libs/services/src/lib/server-storage/server-migration-xtream.ts @@ -15,14 +15,21 @@ const SNAPSHOTS = 'xtream-collection-items'; export function typeLegacyXtreamRefs(data: XtreamUserData): XtreamUserData { const snapshots = isRecord(data[SNAPSHOTS]) ? data[SNAPSHOTS] : {}; const typed = (key: unknown): unknown => { - if (typeof key !== 'number') { + // Bare ids were stored as numbers and, older still, numeric strings. + const id = + typeof key === 'number' + ? key + : typeof key === 'string' && /^\d+$/.test(key) + ? Number(key) + : null; + if (id === null) { return key; } - const snapshot = snapshots[String(key)]; + const snapshot = snapshots[String(id)]; return isRecord(snapshot) && - snapshot['xtream_id'] === key && + snapshot['xtream_id'] === id && typeof snapshot['type'] === 'string' - ? `${snapshot['type']}:${key}` + ? `${snapshot['type']}:${id}` : key; }; 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 7c54c9c76..b32df1fc5 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,10 @@ 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}`; +/** 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) => + `${DEFERRED_EDITS_KEY}:${instanceId}`; /** * Another database behind this address (a new volume, another backend): @@ -33,6 +37,7 @@ export function claimCache(local: UserDataStorage, instanceId: string): void { } if (owner) { local.removeItem(writesKey(owner)); + local.removeItem(deferredKey(owner)); } local.setItem(CACHE_OWNER_KEY, instanceId); } catch { @@ -119,3 +124,73 @@ export function newestWrites( } return [...newest.values()]; } + +export interface SavedDeferredEdit { + readonly namespace: ServerUserStateNamespace; + readonly playlistId: string; + readonly before: unknown; + readonly after: unknown; +} + +export function readDeferredEdits( + local: UserDataStorage, + instanceId: string +): SavedDeferredEdit[] { + let stored: unknown; + try { + stored = JSON.parse(local.getItem(deferredKey(instanceId)) ?? '[]'); + } catch { + return []; + } + const edits: SavedDeferredEdit[] = []; + for (const edit of Array.isArray(stored) + ? (stored as Record[]) + : []) { + const namespace = edit?.['namespace']; + const playlistId = edit?.['playlistId']; + if ( + typeof namespace === 'string' && + isServerUserStateNamespace(namespace) && + typeof playlistId === 'string' + ) { + // JSON has no undefined: absent sides are flagged. + edits.push({ + namespace, + playlistId, + before: edit['noBefore'] ? undefined : edit['before'], + after: edit['noAfter'] ? undefined : edit['after'], + }); + } + } + return edits; +} + +export function saveDeferredEdits( + local: UserDataStorage, + instanceId: string, + edits: readonly SavedDeferredEdit[] +): void { + try { + if (edits.length === 0) { + local.removeItem(deferredKey(instanceId)); + return; + } + local.setItem( + deferredKey(instanceId), + JSON.stringify( + edits.map((edit) => ({ + namespace: edit.namespace, + playlistId: edit.playlistId, + ...(edit.before === undefined + ? { noBefore: true } + : { before: edit.before }), + ...(edit.after === undefined + ? { noAfter: true } + : { after: edit.after }), + })) + ) + ); + } catch { + // Storage unavailable or full: the edits stay in memory only. + } +} 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 8f6499a22..2f0d2b98e 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 @@ -569,4 +569,94 @@ describe('ServerUserDataStorage', () => { { namespace: 'xtream-favorites', playlistId: 'p1', value: ['new'] }, ]); }); + + it('keeps two early additions to an empty entry', async () => { + const { api, storage } = setup(); + let answer: (value: unknown) => void = () => undefined; + api.getJson.mockReturnValueOnce( + new Promise((resolve) => (answer = resolve)) + ); + + const firstRead = storage.hydrate(); + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['A'] })); + storage.setItem('xtream-favorites', JSON.stringify({ p1: ['A', 'B'] })); + answer({ entries: { 'xtream-favorites': { p1: ['S'] } } }); + await firstRead; + await storage.flush(); + + expect(api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/p1', + { value: ['S', 'A', 'B'] } + ); + }); + + it('keeps early edits across a reload before the first read', async () => { + const first = setup(); + first.api.getJson.mockReturnValueOnce(new Promise(() => undefined)); + void first.storage.hydrate(); + first.storage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['mine'] }) + ); + + const api = { + ...first.api, + sendJson: jest.fn().mockResolvedValue(undefined), + getJson: jest.fn().mockResolvedValue({ + entries: { 'xtream-favorites': { p1: ['theirs'] } }, + }), + }; + const reloaded = new ServerUserDataStorage( + api as never, + first.local, + jest.fn() + ); + await reloaded.hydrate(); + await reloaded.flush(); + + expect(api.sendJson).toHaveBeenCalledWith( + 'PUT', + '/user-state/xtream-favorites/p1', + { value: ['theirs', 'mine'] } + ); + }); + + 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 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( + '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']); + }); }); 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 ba8585eeb..5c88713af 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 @@ -17,7 +17,9 @@ import { type DeferredEdit, refreshedNamespace } from './server-user-data-edit'; import { claimCache, newestWrites, + readDeferredEdits, readUnsentWrites, + saveDeferredEdits, saveUnsentWrites, } from './server-unsent-writes'; @@ -76,6 +78,9 @@ export class ServerUserDataStorage implements UserDataStorage { this.queue(write.namespace, write.playlistId, write.value); } this.unsentSaved = this.pending.size > 0; + for (const edit of readDeferredEdits(local, instanceId)) { + this.deferred.set(`${edit.namespace}|${edit.playlistId}`, edit); + } } /** Whether a write is queued, being retried, or still travelling. */ @@ -142,7 +147,12 @@ export class ServerUserDataStorage implements UserDataStorage { unsaved.add(key); } } + // 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}`); + } const deferred = [...this.deferred.values()]; + const refreshed = new Map>(); for (const namespace of SERVER_USER_STATE_NAMESPACES) { const keepCached = new Set(); for (const key of unsaved) { @@ -159,6 +169,7 @@ export class ServerUserDataStorage implements UserDataStorage { keepCached, deferred.filter((edit) => edit.namespace === namespace) ); + refreshed.set(namespace, value); try { this.local.setItem( SERVER_CACHE_PREFIX + namespace, @@ -188,9 +199,10 @@ export class ServerUserDataStorage implements UserDataStorage { this.queue( edit.namespace, edit.playlistId, - this.synced.get(edit.namespace)?.[edit.playlistId] + refreshed.get(edit.namespace)?.[edit.playlistId] ); } + saveDeferredEdits(this.local, this.instanceId, []); this.scheduleFlush(); } } @@ -284,12 +296,17 @@ export class ServerUserDataStorage implements UserDataStorage { return; } const key = `${namespace}|${playlistId}`; + const earlier = this.deferred.get(key); this.deferred.set(key, { namespace, playlistId, - before: this.deferred.get(key)?.before ?? previous[playlistId], + // An earlier edit's baseline stays, even when it was empty. + before: earlier ? earlier.before : previous[playlistId], after: value, }); + saveDeferredEdits(this.local, this.instanceId, [ + ...this.deferred.values(), + ]); }; for (const [playlistId, value] of Object.entries(next)) { if ( @@ -323,7 +340,12 @@ export class ServerUserDataStorage implements UserDataStorage { } private scheduleFlush(): void { - if (this.flushTimer !== null || this.pending.size === 0) { + if (this.pending.size === 0) { + return; + } + // Saved at once: another tab's re-read must not replace them. + this.saveUnsent(); + if (this.flushTimer !== null) { return; } this.flushTimer = setTimeout(() => {