diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 1b4408f78..6c3f373b6 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -233,7 +233,9 @@ settings and guards read them in every build. which copies `sessionStorage`, leaves the running tab its writes. A page load that still looked live (or whose close flag storage refused) is checked again every 30 s and, once it does not, adopted like a closed - tab's: its edits go ahead of this page's newer ones; the writes of a tab closed before it + tab's: its edits go ahead of this page's newer ones, and without a + server version this page read they are sent so that they conflict and + replay; the writes of a tab closed before it could send them are queued, ahead of its own, by the next re-read in another tab once that tab's record has gone stale; the result replays the closed tab's edits first and this tab's newer ones last. Tabs never clear each other's. diff --git a/libs/services/src/lib/server-storage/server-user-data-adopt.ts b/libs/services/src/lib/server-storage/server-user-data-adopt.ts index e79c96893..617576586 100644 --- a/libs/services/src/lib/server-storage/server-user-data-adopt.ts +++ b/libs/services/src/lib/server-storage/server-user-data-adopt.ts @@ -24,6 +24,12 @@ export function adoptEdits( const key = `${namespace}|${playlistId}`; const adopted = uncertain(steps); const queued = writes.isQueued(key) ? writes.unsent(key) : undefined; + // Without a server version this page read, the write must conflict and + // be replayed on the server's value, never overwrite it blindly. + const unknown = + foreign || + (queued ? queued.tag : writes.tags.of(namespace, playlistId)) === + undefined; if (queued) { // This tab's queued edits are newer: they replay last. const all = [...adopted, ...queued.steps]; @@ -32,12 +38,12 @@ export function adoptEdits( ...queued, value, steps: all, - tag: foreign ? UNKNOWN_TAG : queued.tag, + tag: unknown ? UNKNOWN_TAG : queued.tag, }); return value; } const value = replaySteps(current, steps); - writes.queue(namespace, playlistId, value, value, { foreign }); + writes.queue(namespace, playlistId, value, value, { foreign: unknown }); const entry = writes.unsent(key); if (entry) { writes.put({ ...entry, steps: [...adopted, ...entry.steps] }); 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 228e6b9e5..73b2fcf24 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 @@ -1182,5 +1182,39 @@ describe('ServerUserDataStorage with other devices', () => { expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']); }); + + it("replays its writes on the server's value when this page never read it", async () => { + const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } }); + const local = memoryStorage(); + const tabStore = memoryStorage(); + const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000); + await lastPageLoad(server, local, tabStore, async (page) => { + await page.hydrate(); + page.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['S', 'X'] }) + ); + await page.flush(); + }); + // Another device saves Z; this page's reads keep failing. + server.state['xtream-favorites']['p1'] = ['S', 'Z']; + const reloaded = new ServerUserDataStorage( + { ...server.api, getJson: jest.fn(offline) } as never, + local, + jest.fn(), + 'default', + tabStore + ); + + clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1); + reloaded.touch(); + await reloaded.flush(); + await reloaded.flush(); + clock.mockRestore(); + + expect( + [...(server.state['xtream-favorites']['p1'] as string[])].sort() + ).toEqual(['S', 'X', 'Z']); + }); }); });