From 115465755bf29a67e3c2f1ff2bc7c81374dd9049 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 12 Oct 2026 23:11:26 +0200 Subject: [PATCH] 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 --- apps/web/src/app/services/pwa.service.ts | 11 ++-- docs/architecture/pwa-self-hosted.md | 15 +++-- .../lib/server-storage-status.service.spec.ts | 18 ++++++ .../src/lib/server-storage-status.service.ts | 10 +++- .../server-access-token.interceptor.ts | 2 +- .../server-storage/server-api-client.spec.ts | 17 ++++++ .../lib/server-storage/server-api-client.ts | 9 ++- .../server-migration.service.spec.ts | 10 ++++ .../server-migration.service.ts | 44 +++++++++++---- .../server-playlist-document-store.spec.ts | 30 +++++++++- .../server-playlist-document-store.ts | 39 ++++++------- .../server-storage/server-user-data-edit.ts | 6 +- .../server-user-data-storage.ts | 24 +++++++- .../server-user-data-sync.spec.ts | 55 +++++++++++++++++++ 14 files changed, 232 insertions(+), 58 deletions(-) diff --git a/apps/web/src/app/services/pwa.service.ts b/apps/web/src/app/services/pwa.service.ts index 52c63b94a..2fca37d6d 100644 --- a/apps/web/src/app/services/pwa.service.ts +++ b/apps/web/src/app/services/pwa.service.ts @@ -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); } } } diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 756b272ff..add1e69b0 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -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::`). A shared registry in `localStorage` (`iptvnator-server:unconfirmed:`, 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:`), 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. diff --git a/libs/services/src/lib/server-storage-status.service.spec.ts b/libs/services/src/lib/server-storage-status.service.spec.ts index 71325d450..6755593ef 100644 --- a/libs/services/src/lib/server-storage-status.service.spec.ts +++ b/libs/services/src/lib/server-storage-status.service.spec.ts @@ -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(); + }); }); diff --git a/libs/services/src/lib/server-storage-status.service.ts b/libs/services/src/lib/server-storage-status.service.ts index ec36f91b2..0d0c37411 100644 --- a/libs/services/src/lib/server-storage-status.service.ts +++ b/libs/services/src/lib/server-storage-status.service.ts @@ -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'); } diff --git a/libs/services/src/lib/server-storage/server-access-token.interceptor.ts b/libs/services/src/lib/server-storage/server-access-token.interceptor.ts index afd0f2ed2..098136417 100644 --- a/libs/services/src/lib/server-storage/server-access-token.interceptor.ts +++ b/libs/services/src/lib/server-storage/server-access-token.interceptor.ts @@ -47,7 +47,7 @@ export class ServerAccessTokenInterceptor implements HttpInterceptor { error.headers.get('WWW-Authenticate') ) ) { - this.status.reportAccessDenied(); + this.status.reportAccessDenied(token); } }, }) diff --git a/libs/services/src/lib/server-storage/server-api-client.spec.ts b/libs/services/src/lib/server-storage/server-api-client.spec.ts index 1ff88268d..a4f65dca0 100644 --- a/libs/services/src/lib/server-storage/server-api-client.spec.ts +++ b/libs/services/src/lib/server-storage/server-api-client.spec.ts @@ -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() diff --git a/libs/services/src/lib/server-storage/server-api-client.ts b/libs/services/src/lib/server-storage/server-api-client.ts index 56fdaad5d..6fd23c077 100644 --- a/libs/services/src/lib/server-storage/server-api-client.ts +++ b/libs/services/src/lib/server-storage/server-api-client.ts @@ -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 { 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', diff --git a/libs/services/src/lib/server-storage/server-migration.service.spec.ts b/libs/services/src/lib/server-storage/server-migration.service.spec.ts index d62011624..e7fc82173 100644 --- a/libs/services/src/lib/server-storage/server-migration.service.spec.ts +++ b/libs/services/src/lib/server-storage/server-migration.service.spec.ts @@ -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); + }); }); diff --git a/libs/services/src/lib/server-storage/server-migration.service.ts b/libs/services/src/lib/server-storage/server-migration.service.ts index f8ff50802..ddd97f361 100644 --- a/libs/services/src/lib/server-storage/server-migration.service.ts +++ b/libs/services/src/lib/server-storage/server-migration.service.ts @@ -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( '/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 + >(); + 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(); 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 { +/** A browser map, `{}` when absent, `null` when it cannot be read. */ +function readLegacyMap(key: string): Record | null { + let value: string | null; try { - const parsed: unknown = value ? JSON.parse(value) : {}; - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) - ? (parsed as Record) - : {}; + 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) + : null; + } catch { + return null; + } } diff --git a/libs/services/src/lib/server-storage/server-playlist-document-store.spec.ts b/libs/services/src/lib/server-storage/server-playlist-document-store.spec.ts index 9a56c4959..0d753c547 100644 --- a/libs/services/src/lib/server-storage/server-playlist-document-store.spec.ts +++ b/libs/services/src/lib/server-storage/server-playlist-document-store.spec.ts @@ -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. diff --git a/libs/services/src/lib/server-storage/server-playlist-document-store.ts b/libs/services/src/lib/server-storage/server-playlist-document-store.ts index aff132337..bad254eeb 100644 --- a/libs/services/src/lib/server-storage/server-playlist-document-store.ts +++ b/libs/services/src/lib/server-storage/server-playlist-document-store.ts @@ -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)[ + 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(); - /** The version each playlist was last read or written at. */ - private readonly basis = new Map(); /** `id|version` a write was refused on (another device wrote since). */ private readonly refused = new Set(); /** 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 = 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)[SERVED_VERSION] = etag; const record = copy as unknown as Record; for (const field of SERVER_PAYLOAD_FIELDS) { const value = record[field]; 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 67527889f..cebd2a709 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 @@ -18,7 +18,9 @@ export function refreshedNamespace( server: Readonly>, cached: Readonly>, keepCached: ReadonlySet, - 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 { const value: Record = { ...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 { 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 5a29d8adb..ad6411a52 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 @@ -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(); const refreshed = new Map>(); for (const namespace of SERVER_USER_STATE_NAMESPACES) { const keepCached = new Set(); @@ -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(); 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 ca92b6542..fd1564523 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 @@ -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']); + }); });