From 586ba74ea77dfb18f9afa3545e6a515eb5f98f52 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 13 Oct 2026 00:37:05 +0200 Subject: [PATCH] fix(server-storage): adopted writes without a known version conflict instead of overwriting A page that never read the server has no version tags, so an adopted write went out unconditionally and replaced newer server data with its cached value. Adopted edits now use the never-matching version when this page knows none (and for a queued write without a tag), so the server answers 412 and the edits are replayed on its value. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/pwa-self-hosted.md | 4 ++- .../server-storage/server-user-data-adopt.ts | 10 ++++-- .../server-user-data-sync.spec.ts | 34 +++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) 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']); + }); }); });