From 132d6ccc6341cffd5180d3ce61d7ee986913906c Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 13 Oct 2026 09:29:36 +0200 Subject: [PATCH] fix(server-storage): a re-read sent before a playlist delete skips that playlist ServerUserDataStorage records each successful delete; a re-read sent before it drops the deleted playlist's entries and tags from the server's answer (withoutDeleted), so a late answer cannot write the deleted favorites and positions back into the cache. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/pwa-self-hosted.md | 2 +- .../server-storage/server-user-data-edit.ts | 41 ++++++++++++++++++- .../server-user-data-storage.ts | 11 ++++- .../server-user-data-sync.spec.ts | 32 +++++++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 3402312b1..f6858302e 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -262,7 +262,7 @@ settings and guards read them in every build. new tag or conflict like any other, and one behind a travelling write is taken from the queue only when that one is answered. While a playlist is being deleted its edits wait; the delete drops them only when it - succeeds. Edits made before the first successful read + succeeds, and a re-read sent before it skips the deleted playlist. 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. On this tab's recovered unsent diff --git a/libs/services/src/lib/server-storage/server-user-data-edit.ts b/libs/services/src/lib/server-storage/server-user-data-edit.ts index c17812c82..311fdbb4f 100644 --- a/libs/services/src/lib/server-storage/server-user-data-edit.ts +++ b/libs/services/src/lib/server-storage/server-user-data-edit.ts @@ -1,4 +1,8 @@ -import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces'; +import { + SERVER_USER_STATE_NAMESPACES, + type ServerUserStateNamespace, + type ServerUserStateResponse, +} from '@iptvnator/shared/interfaces'; import { itemKey } from './server-migration-merge'; import type { EditStep } from './server-user-data-entries'; @@ -144,3 +148,38 @@ export function mergeEdits( } return merged; } + +/** + * A server read without the playlists deleted after it was sent (an id of + * `undefined` stands for every playlist). + */ +export function withoutDeleted( + read: ServerUserStateResponse, + deleted: readonly (string | undefined)[] +): ServerUserStateResponse { + if (deleted.length === 0) { + return read; + } + const keep = (map: Record = {}): Record => { + const kept: Record = {}; + for (const [id, value] of Object.entries(map)) { + if (!deleted.some((gone) => gone === undefined || gone === id)) { + kept[id] = value; + } + } + return kept; + }; + const entries = {} as Record>; + const tags = {} as Record>; + for (const namespace of SERVER_USER_STATE_NAMESPACES) { + entries[namespace] = keep(read.entries[namespace]); + tags[namespace] = keep(read.tags?.[namespace]) as Record< + string, + string + >; + } + return { + entries: entries as ServerUserStateResponse['entries'], + ...(read.tags ? { tags: tags as ServerUserStateResponse['tags'] } : {}), + }; +} 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 4833acdf6..d8e710e27 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 @@ -18,6 +18,7 @@ import { type DeferredEdit, mergeEdits, refreshedNamespace, + withoutDeleted, } from './server-user-data-edit'; import { UserDataWriteQueue } from './server-user-data-queue'; import { @@ -62,6 +63,8 @@ export class ServerUserDataStorage implements UserDataStorage { private hydrated = false; /** The previous page load in this tab, while it still looks live. */ private awaited: string | null = null; + /** Playlists deleted here, in order: a read sent earlier skips them. */ + private readonly deleted: (string | undefined)[] = []; /** Closed tabs' page loads this one took over (once each). */ private readonly adopted = new Set(); /** Page loads taken over whose copy stays until this one saves. */ @@ -302,6 +305,7 @@ export class ServerUserDataStorage implements UserDataStorage { try { await this.writes.whenSent(); await remove(); + this.deleted.push(playlistId); for (const key of [...this.deferred.keys()].filter(matches)) { this.deferred.delete(key); } @@ -316,9 +320,12 @@ export class ServerUserDataStorage implements UserDataStorage { private async hydrateNow(): Promise { // Edits from here on may miss the read: queued, or still travelling. const editsBefore = this.writes.lastEdit; + const deletesBefore = this.deleted.length; await this.writes.flush(); - const { entries, tags } = - await this.api.getJson('/user-state'); + const { entries, tags } = withoutDeleted( + await this.api.getJson('/user-state'), + this.deleted.slice(deletesBefore) + ); const unsaved = this.writes.unsavedSince(editsBefore); // Another tab's unconfirmed writes are in the shared cache too. for (const key of othersUnconfirmed( 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 73b2fcf24..05c874071 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 @@ -1217,4 +1217,36 @@ describe('ServerUserDataStorage with other devices', () => { ).toEqual(['S', 'X', 'Z']); }); }); + + it('keeps a playlist deleted while a re-read was on its way out of the cache', async () => { + const server = fakeServer({ + 'xtream-favorites': { p1: ['a'], p2: ['b'] }, + }); + const phone = new ServerUserDataStorage( + server.api as never, + memoryStorage(), + jest.fn() + ); + await phone.hydrate(); + const get = server.api.getJson.getMockImplementation(); + if (!get) { + throw new Error('The fake server answers getJson'); + } + let answer: () => void = () => undefined; + server.api.getJson.mockImplementationOnce(async () => { + // The server answers with what it had when the read was sent. + const before = await get(); + return new Promise((resolve) => (answer = () => resolve(before))); + }); + + const reading = phone.hydrate(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await phone.deleting('p1', async () => { + delete server.state['xtream-favorites']['p1']; + }); + answer(); + await reading; + + expect(favorites(phone)).toEqual({ p2: ['b'] }); + }); });