mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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:
1 parent
de87c1e4d3
commit
132d6ccc63
4 files changed
+82
-4
No files matched your search
@@ -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'] });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user