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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 09:29:36 +02:00
1 parent de87c1e4d3
commit 132d6ccc63
4 files changed
+82 -4

No files matched your search

+1 -1
View File
@@ -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
@@ -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 = <T>(map: Record<string, T> = {}): Record<string, T> => {
const kept: Record<string, T> = {};
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<string, Record<string, unknown>>;
const tags = {} as Record<string, Record<string, string>>;
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'] } : {}),
};
}
@@ -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<string>();
/** 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<void> {
// 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<ServerUserStateResponse>('/user-state');
const { entries, tags } = withoutDeleted(
await this.api.getJson<ServerUserStateResponse>('/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(
@@ -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'] });
});
});