mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
fix(server-storage): copy-bound portal versions, stale 401s, unreadable browser maps, early edits on recovered writes
- A served copy carries the version it came from (a symbol property that spreads keep and JSON drops), so a mutation of a portal document names the version it was built on, not the latest read. - A 401 for a token replaced since (a new sign-in) neither clears the new token nor reports unauthorized, in the API client, the HttpClient interceptor and PwaService's proxy calls. - Moving browser data counts a browser user-data map that cannot be read as a failure, so the move stays on offer. - An edit made before the first read on top of this tab's recovered unsent write is applied to that write, not to the server's value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
067190e1df
commit
115465755b
14 files changed
+232
-58
No files matched your search
@@ -16,6 +16,7 @@ import {
|
||||
import {
|
||||
DataService,
|
||||
isServerAccessChallenge,
|
||||
readServerAccessToken,
|
||||
serverAuthorizationHeaders,
|
||||
ServerStorageStatusService,
|
||||
} from '@iptvnator/services';
|
||||
@@ -201,6 +202,7 @@ export class PwaService extends DataService {
|
||||
);
|
||||
|
||||
try {
|
||||
const token = readServerAccessToken();
|
||||
const response = await fetch(
|
||||
`${this.corsProxyUrl}/connectivity-guard/reset`,
|
||||
{
|
||||
@@ -213,7 +215,7 @@ export class PwaService extends DataService {
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
this.noteServerAccess(response);
|
||||
this.noteServerAccess(response, token);
|
||||
|
||||
if (!response.ok) {
|
||||
return { reset: false };
|
||||
@@ -647,10 +649,11 @@ export class PwaService extends DataService {
|
||||
logPortalDebugRequest(context);
|
||||
|
||||
// Make the fetch request
|
||||
const accessToken = readServerAccessToken();
|
||||
const response = await fetch(requestUrl, {
|
||||
headers: serverAuthorizationHeaders(),
|
||||
});
|
||||
this.noteServerAccess(response);
|
||||
this.noteServerAccess(response, accessToken);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
@@ -804,7 +807,7 @@ export class PwaService extends DataService {
|
||||
}
|
||||
|
||||
/** A token changed on the server shows up on any backend call. */
|
||||
private noteServerAccess(response: Response): void {
|
||||
private noteServerAccess(response: Response, token: string | null): void {
|
||||
if (
|
||||
response.status === 401 &&
|
||||
isServerAccessChallenge(
|
||||
@@ -812,7 +815,7 @@ export class PwaService extends DataService {
|
||||
response.headers.get('WWW-Authenticate')
|
||||
)
|
||||
) {
|
||||
this.serverStatus.reportAccessDenied();
|
||||
this.serverStatus.reportAccessDenied(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,8 @@ settings and guards read them in every build.
|
||||
ETag and uploads a document whose payload it served unchanged with
|
||||
`keepPayload=1`, so a favorite or a rename does not resend a large M3U.
|
||||
A mutation carries `If-Match` with the version it was read at (of its
|
||||
payload, else of the playlist's last read, as for portal documents): when
|
||||
payload, else of the served copy it was built from, as for portal
|
||||
documents): when
|
||||
another device wrote in between, the server answers `412`, the store
|
||||
throws `PlaylistWriteConflict`, and `PlaylistsService`'s per-playlist
|
||||
write queue runs the mutation again on a fresh read (a retry on the same,
|
||||
@@ -248,8 +249,9 @@ settings and guards read them in every build.
|
||||
being deleted its edits wait; the delete drops them only when it
|
||||
succeeds. 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. They are saved per page load too
|
||||
arrives (`applyEdit`), or to this tab's recovered unsent write when it has
|
||||
one: a new device or a stale cache adds its favorite instead of replacing
|
||||
the server's list. They are saved per page load too
|
||||
(`iptvnator-server:deferred-edits:<instanceId>:<page>`). A shared
|
||||
registry in `localStorage` (`iptvnator-server:unconfirmed:<instanceId>`,
|
||||
one record per page load, renewed every 30 s while it has unsent writes,
|
||||
@@ -279,7 +281,8 @@ settings and guards read them in every build.
|
||||
`ServerAccessTokenInterceptor` adds the header to `HttpClient` calls to the
|
||||
backend, and `PwaService` adds it to its two raw `fetch` calls. Both turn
|
||||
the guard's 401 (`isServerAccessChallenge`) into `reportAccessDenied()`,
|
||||
so a token changed on the server sends an open page back to sign-in.
|
||||
so a token changed on the server sends an open page back to sign-in; a
|
||||
late 401 for a token replaced since (a new sign-in) changes nothing.
|
||||
- **Moving browser data.** `ServerMigrationService` offers, once per server
|
||||
instance (marker `iptvnator-server:migration:<instanceId>`), to move the
|
||||
playlists this browser kept in IndexedDB (`planServerMigration`). A
|
||||
@@ -295,8 +298,8 @@ settings and guards read them in every build.
|
||||
lists (playback positions by content type and Xtream id, renamed to the
|
||||
server playlist) and merges maps, the server's value winning on a
|
||||
conflict. Parental locks stay in the browser but move to the server
|
||||
playlist id with it; while the lock store cannot be read the move counts
|
||||
as failed. Browser data is never
|
||||
playlist id with it; while the lock store or a browser user-data map
|
||||
cannot be read the move counts as failed. Browser data is never
|
||||
deleted, the move does not start while user-data writes are unsent, and
|
||||
the marker is written only when nothing failed, so Settings → Server
|
||||
keeps offering a partial move.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
import { SERVER_ACCESS_TOKEN_STORAGE_KEY } from './server-access-token';
|
||||
import { ServerStorageStatusService } from './server-storage-status.service';
|
||||
|
||||
const instance: ServerInstanceInfo = {
|
||||
@@ -48,4 +49,21 @@ describe('ServerStorageStatusService', () => {
|
||||
status.reportOffline();
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
});
|
||||
|
||||
it('ignores a refusal of a token replaced since', () => {
|
||||
status.activate(instance, jest.fn());
|
||||
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 'new');
|
||||
|
||||
status.reportAccessDenied('old');
|
||||
expect(status.state()).toBe('online');
|
||||
expect(localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)).toBe(
|
||||
'new'
|
||||
);
|
||||
|
||||
status.reportAccessDenied('new');
|
||||
expect(status.state()).toBe('unauthorized');
|
||||
expect(
|
||||
localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { computed, Injectable, signal } from '@angular/core';
|
||||
import type { ServerInstanceInfo } from '@iptvnator/shared/interfaces';
|
||||
import { clearServerAccessToken } from './server-access-token';
|
||||
import {
|
||||
clearServerAccessToken,
|
||||
readServerAccessToken,
|
||||
} from './server-access-token';
|
||||
|
||||
export type ServerConnectionState =
|
||||
'inactive' | 'online' | 'offline' | 'unauthorized';
|
||||
@@ -57,8 +60,9 @@ export class ServerStorageStatusService {
|
||||
* The access guard refused a request outside the storage API (a
|
||||
* provider proxy call): the stored token is stale, ask for a new one.
|
||||
*/
|
||||
reportAccessDenied(): void {
|
||||
if (this.isActive()) {
|
||||
reportAccessDenied(sentToken: string | null): void {
|
||||
// A late answer to a token replaced since (a new sign-in) is moot.
|
||||
if (this.isActive() && readServerAccessToken() === sentToken) {
|
||||
clearServerAccessToken();
|
||||
this.stateSignal.set('unauthorized');
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export class ServerAccessTokenInterceptor implements HttpInterceptor {
|
||||
error.headers.get('WWW-Authenticate')
|
||||
)
|
||||
) {
|
||||
this.status.reportAccessDenied();
|
||||
this.status.reportAccessDenied(token);
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -178,6 +178,23 @@ describe('ServerApiClient', () => {
|
||||
expect(status.reportUnauthorized).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a token stored while a request with the old one ran', async () => {
|
||||
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 'old');
|
||||
const fetchFn = jest.fn(async () => {
|
||||
// The user signs in again before the old request is answered.
|
||||
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 'new');
|
||||
return response(401, { code: 'unauthorized' });
|
||||
});
|
||||
|
||||
await expect(
|
||||
client(fetchFn).getJson('/playlists')
|
||||
).rejects.toBeInstanceOf(ServerApiError);
|
||||
expect(localStorage.getItem(SERVER_ACCESS_TOKEN_STORAGE_KEY)).toBe(
|
||||
'new'
|
||||
);
|
||||
expect(status.reportUnauthorized).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes 304 through and maps error bodies', async () => {
|
||||
const fetchFn = jest
|
||||
.fn()
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
clearServerAccessToken,
|
||||
readServerAccessToken,
|
||||
serverAuthorizationHeaders,
|
||||
} from '../server-access-token';
|
||||
import type { ServerStorageStatusService } from '../server-storage-status.service';
|
||||
@@ -65,6 +66,7 @@ export class ServerApiClient {
|
||||
|
||||
async request(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const isWrite = (init.method ?? 'GET') !== 'GET';
|
||||
const token = readServerAccessToken();
|
||||
let response: Response;
|
||||
try {
|
||||
if (isWrite && this.options.holdWrites) {
|
||||
@@ -93,8 +95,11 @@ export class ServerApiClient {
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
clearServerAccessToken();
|
||||
this.options.status.reportUnauthorized();
|
||||
// A late answer to a token replaced since (a new sign-in) is moot.
|
||||
if (readServerAccessToken() === token) {
|
||||
clearServerAccessToken();
|
||||
this.options.status.reportUnauthorized();
|
||||
}
|
||||
throw new ServerApiError(
|
||||
401,
|
||||
'unauthorized',
|
||||
|
||||
@@ -386,4 +386,14 @@ describe('ServerMigrationService', () => {
|
||||
expect(result.failed).toBeGreaterThan(0);
|
||||
expect(parentalLock.replacePlaylistLocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the move on offer when browser favorites cannot be read', async () => {
|
||||
localStorage.setItem('xtream-favorites', '{not json');
|
||||
const migration = TestBed.inject(ServerMigrationService);
|
||||
|
||||
const result = await migration.moveBrowserData();
|
||||
|
||||
expect(result.failed).toBeGreaterThan(0);
|
||||
expect(migration.browserDataMoved()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DbStores,
|
||||
SERVER_USER_STATE_NAMESPACES,
|
||||
type Playlist,
|
||||
type ServerUserStateNamespace,
|
||||
type ServerUserStateResponse,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
@@ -207,12 +208,21 @@ export class ServerMigrationService {
|
||||
await this.session.api.getJson<ServerUserStateResponse>(
|
||||
'/user-state'
|
||||
);
|
||||
const legacy = new Map(
|
||||
SERVER_USER_STATE_NAMESPACES.map((namespace) => [
|
||||
namespace,
|
||||
parseMap(readLocal(namespace)),
|
||||
])
|
||||
);
|
||||
// A map this browser has but cannot read fails the move, so it
|
||||
// stays on offer instead of being marked done without it.
|
||||
let unreadable = 0;
|
||||
const legacy = new Map<
|
||||
ServerUserStateNamespace,
|
||||
Record<string, unknown>
|
||||
>();
|
||||
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
|
||||
const map = readLegacyMap(namespace);
|
||||
if (map) {
|
||||
legacy.set(namespace, map);
|
||||
} else {
|
||||
unreadable += 1;
|
||||
}
|
||||
}
|
||||
const copies = new Map<string, XtreamUserData[]>();
|
||||
for (const [browserId, serverId] of targets) {
|
||||
const copy: XtreamUserData = {};
|
||||
@@ -230,7 +240,7 @@ export class ServerMigrationService {
|
||||
}
|
||||
}
|
||||
|
||||
let failed = 0;
|
||||
let failed = unreadable;
|
||||
for (const [serverId, browserCopies] of copies) {
|
||||
const stored: XtreamUserData = {};
|
||||
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
|
||||
@@ -346,13 +356,23 @@ function readLocal(key: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function parseMap(value: string | null): Record<string, unknown> {
|
||||
/** A browser map, `{}` when absent, `null` when it cannot be read. */
|
||||
function readLegacyMap(key: string): Record<string, unknown> | null {
|
||||
let value: string | null;
|
||||
try {
|
||||
const parsed: unknown = value ? JSON.parse(value) : {};
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
value = localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (value === null) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,8 @@ describe('ServerPlaylistDocumentStore', () => {
|
||||
expect(api.request.mock.calls[1][1]).toEqual({
|
||||
headers: { 'If-None-Match': 'W/"b.0.1"' },
|
||||
});
|
||||
expect(second).toEqual(doc);
|
||||
// structuredClone drops the served-version symbol, as JSON does.
|
||||
expect(structuredClone(second)).toEqual(doc);
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
@@ -77,7 +78,11 @@ describe('ServerPlaylistDocumentStore', () => {
|
||||
|
||||
const [method, path, body] = api.sendJson.mock.calls[0];
|
||||
expect([method, path]).toEqual(['PUT', '/playlists/p1?keepPayload=1']);
|
||||
expect(body).toEqual({ _id: 'p1', title: 'Family', favorites: ['a'] });
|
||||
expect(JSON.parse(JSON.stringify(body))).toEqual({
|
||||
_id: 'p1',
|
||||
title: 'Family',
|
||||
favorites: ['a'],
|
||||
});
|
||||
});
|
||||
|
||||
it('uploads a new payload in full', async () => {
|
||||
@@ -181,6 +186,27 @@ describe('ServerPlaylistDocumentStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('names the version of the copy a mutation was built on', async () => {
|
||||
const { api, store } = setup();
|
||||
const portal = { _id: 'p2', title: 'Portal', macAddress: 'm' };
|
||||
api.request
|
||||
.mockResolvedValueOnce(fakeResponse(200, portal, '"b.0.1"'))
|
||||
// Another device wrote; a later read sees its version.
|
||||
.mockResolvedValueOnce(
|
||||
fakeResponse(200, { ...portal, title: 'Theirs' }, '"b.0.2"')
|
||||
);
|
||||
const older = await store.dbGetAppPlaylist('p2');
|
||||
await store.dbGetAppPlaylist('p2');
|
||||
if (!older) throw new Error('expected a document');
|
||||
api.sendJson.mockResolvedValueOnce(fakeResponse(200, {}, '"b.0.3"'));
|
||||
|
||||
await store.dbUpsertAppPlaylist({ ...older, title: 'Mine' });
|
||||
|
||||
expect(api.sendJson.mock.calls[0][3]).toEqual({
|
||||
'If-Match': '"b.0.1"',
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps checking a playlist after another one's refusal", async () => {
|
||||
const { api, store } = setup();
|
||||
// Both playlists are at revision 1 of the same boot: one ETag.
|
||||
|
||||
@@ -11,6 +11,16 @@ import {
|
||||
} from '../playlist-storage-backend';
|
||||
import { ServerApiError, type ServerApiClient } from './server-api-client';
|
||||
|
||||
/** The version a served copy came from; JSON never sends it. */
|
||||
const SERVED_VERSION = Symbol('servedVersion');
|
||||
|
||||
function servedVersion(playlist: Playlist): string | null {
|
||||
const version = (playlist as unknown as Record<symbol, unknown>)[
|
||||
SERVED_VERSION
|
||||
];
|
||||
return typeof version === 'string' ? version : null;
|
||||
}
|
||||
|
||||
interface CachedDocument {
|
||||
readonly etag: string;
|
||||
readonly document: Playlist;
|
||||
@@ -30,8 +40,6 @@ type ServerApi = Pick<
|
||||
*/
|
||||
export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
|
||||
private readonly cache = new Map<string, CachedDocument>();
|
||||
/** The version each playlist was last read or written at. */
|
||||
private readonly basis = new Map<string, string>();
|
||||
/** `id|version` a write was refused on (another device wrote since). */
|
||||
private readonly refused = new Set<string>();
|
||||
/** Served payload object → the ETag of the document it came from. */
|
||||
@@ -68,7 +76,6 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
|
||||
} catch (error) {
|
||||
if (error instanceof ServerApiError && error.status === 404) {
|
||||
this.cache.delete(playlistId);
|
||||
this.basis.delete(playlistId);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
@@ -104,7 +111,6 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
|
||||
})
|
||||
);
|
||||
this.cache.delete(playlistId);
|
||||
this.basis.delete(playlistId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -113,7 +119,6 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
|
||||
this.api.request('/playlists', { method: 'DELETE' })
|
||||
);
|
||||
this.cache.clear();
|
||||
this.basis.clear();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -145,10 +150,10 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
|
||||
const id = playlist._id;
|
||||
const path = `/playlists/${encodeURIComponent(id)}`;
|
||||
const payloadEtag = this.servedPayloadEtag(playlist);
|
||||
// The version it was read at: of its payload, else of the last read
|
||||
// (portal documents have no payload). A retry on the same copy, not
|
||||
// read again after a refusal, writes as before.
|
||||
const read = payloadEtag ?? this.basis.get(id) ?? null;
|
||||
// The version it was read at: of its payload, else of the copy it
|
||||
// was built from (portal documents have no payload). A retry on the
|
||||
// same copy, not read again after a refusal, writes as before.
|
||||
const read = payloadEtag ?? servedVersion(playlist);
|
||||
const headers: Record<string, string> =
|
||||
conditional && read && !this.refused.has(`${id}|${read}`)
|
||||
? { 'If-Match': read }
|
||||
@@ -176,7 +181,6 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
|
||||
if (error instanceof ServerApiError && error.status === 412) {
|
||||
this.refused.add(`${id}|${read}`);
|
||||
this.cache.delete(id);
|
||||
this.basis.delete(id);
|
||||
throw new PlaylistWriteConflict(id);
|
||||
}
|
||||
throw error;
|
||||
@@ -213,7 +217,6 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
|
||||
this.remember(playlist._id, response, playlist);
|
||||
} else {
|
||||
this.cache.delete(playlist._id);
|
||||
this.noteWrite(playlist._id, response);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -229,26 +232,16 @@ export class ServerPlaylistDocumentStore implements PlaylistDocumentStore {
|
||||
} else {
|
||||
this.cache.delete(id);
|
||||
}
|
||||
this.noteWrite(id, response);
|
||||
return etag;
|
||||
}
|
||||
|
||||
/** The version a read or write left the playlist at. */
|
||||
private noteWrite(id: string, response: Response): void {
|
||||
const etag = response.headers.get('etag');
|
||||
if (etag) {
|
||||
this.basis.set(id, etag);
|
||||
} else {
|
||||
this.basis.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private serve(document: Playlist, etag: string | null): Playlist {
|
||||
const copy = structuredClone(document);
|
||||
if (!etag) {
|
||||
return copy;
|
||||
}
|
||||
this.basis.set(document._id, etag);
|
||||
// Spreads copy it along, so a mutation built on this copy names it.
|
||||
(copy as unknown as Record<symbol, string>)[SERVED_VERSION] = etag;
|
||||
const record = copy as unknown as Record<string, unknown>;
|
||||
for (const field of SERVER_PAYLOAD_FIELDS) {
|
||||
const value = record[field];
|
||||
|
||||
@@ -18,7 +18,9 @@ export function refreshedNamespace(
|
||||
server: Readonly<Record<string, unknown>>,
|
||||
cached: Readonly<Record<string, unknown>>,
|
||||
keepCached: ReadonlySet<string>,
|
||||
deferred: readonly DeferredEdit[]
|
||||
deferred: readonly DeferredEdit[],
|
||||
/** What an early edit was made on; the server's entry by default. */
|
||||
baseOf: (playlistId: string) => unknown = (playlistId) => server[playlistId]
|
||||
): Record<string, unknown> {
|
||||
const value: Record<string, unknown> = { ...server };
|
||||
for (const playlistId of keepCached) {
|
||||
@@ -29,7 +31,7 @@ export function refreshedNamespace(
|
||||
}
|
||||
}
|
||||
for (const edit of deferred) {
|
||||
const merged = replaySteps(server[edit.playlistId], edit.steps);
|
||||
const merged = replaySteps(baseOf(edit.playlistId), edit.steps);
|
||||
if (merged === undefined) {
|
||||
delete value[edit.playlistId];
|
||||
} else {
|
||||
|
||||
@@ -324,6 +324,10 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
unsaved.add(key);
|
||||
}
|
||||
const deferred = [...this.deferred.values()];
|
||||
// This tab's own unsent writes (recovered after a reload): an early
|
||||
// edit was made on them, not on the server's value.
|
||||
const own = new Set(this.writes.unconfirmedKeys());
|
||||
const base = new Map<string, unknown>();
|
||||
const refreshed = new Map<string, Record<string, unknown>>();
|
||||
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
|
||||
const keepCached = new Set<string>();
|
||||
@@ -333,11 +337,25 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
keepCached.add(playlistId);
|
||||
}
|
||||
}
|
||||
const cached = this.cache.map(namespace);
|
||||
const early = deferred.filter(
|
||||
(edit) => edit.namespace === namespace
|
||||
);
|
||||
for (const { playlistId } of early) {
|
||||
const key = `${namespace}|${playlistId}`;
|
||||
base.set(
|
||||
key,
|
||||
own.has(key)
|
||||
? cached[playlistId]
|
||||
: entries[namespace]?.[playlistId]
|
||||
);
|
||||
}
|
||||
const value = refreshedNamespace(
|
||||
entries[namespace] ?? {},
|
||||
this.cache.map(namespace),
|
||||
cached,
|
||||
keepCached,
|
||||
deferred.filter((edit) => edit.namespace === namespace)
|
||||
early,
|
||||
(playlistId) => base.get(`${namespace}|${playlistId}`)
|
||||
);
|
||||
refreshed.set(namespace, value);
|
||||
// A value too big for browser storage stays readable in memory,
|
||||
@@ -363,7 +381,7 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
edit.namespace,
|
||||
edit.playlistId,
|
||||
refreshed.get(edit.namespace)?.[edit.playlistId],
|
||||
entries[edit.namespace]?.[edit.playlistId]
|
||||
base.get(`${edit.namespace}|${edit.playlistId}`)
|
||||
);
|
||||
}
|
||||
this.saveDeferred();
|
||||
|
||||
@@ -822,4 +822,59 @@ describe('ServerUserDataStorage with other devices', () => {
|
||||
expect(reloaded.hasUnsentWrites()).toBe(true);
|
||||
expect(saved()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps a recovered write under an early edit made on it', async () => {
|
||||
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
|
||||
const local = memoryStorage();
|
||||
const tabStore = memoryStorage();
|
||||
const offline = {
|
||||
...server.api,
|
||||
sendJson: jest
|
||||
.fn()
|
||||
.mockRejectedValue(new ServerApiError(0, 'network', 'offline')),
|
||||
};
|
||||
const before = new ServerUserDataStorage(
|
||||
offline as never,
|
||||
local,
|
||||
jest.fn(),
|
||||
'default',
|
||||
tabStore
|
||||
);
|
||||
await before.hydrate();
|
||||
before.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['S', 'old'] })
|
||||
);
|
||||
await before.flush();
|
||||
|
||||
// Reloaded: the recovered write fails once, the read works.
|
||||
const send = server.api.sendJson.getMockImplementation();
|
||||
const api = {
|
||||
...server.api,
|
||||
sendJson: jest
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new ServerApiError(0, 'network', 'offline')
|
||||
)
|
||||
.mockImplementation(send),
|
||||
};
|
||||
const reloaded = new ServerUserDataStorage(
|
||||
api as never,
|
||||
local,
|
||||
jest.fn(),
|
||||
'default',
|
||||
tabStore
|
||||
);
|
||||
reloaded.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: [...favorites(reloaded).p1, 'new'] })
|
||||
);
|
||||
await reloaded.hydrate();
|
||||
await reloaded.flush();
|
||||
await reloaded.flush();
|
||||
|
||||
expect(
|
||||
[...(server.state['xtream-favorites']['p1'] as string[])].sort()
|
||||
).toEqual(['S', 'new', 'old']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user