fix(server-storage): conditional writes for every playlist mutation, ordered adoption, keep journals until copied

- Every playlist mutation is sent on the version it was read at: the
  payload's ETag, else the playlist's last read (portal documents have no
  payload). Refused versions are tracked per playlist, so a conflict on
  one never disables the check on another at the same revision. Imports
  and start-up migrations (dbUpsertAppPlaylists) stay unconditional.
- A closed tab's adopted edits replay before this tab's queued ones, from
  the base of its first step, so newer progress is not replaced by older.
- A reload or adoption removes the old page load's saved writes only when
  this page saved its copy; each closed tab is adopted once per page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 21:31:49 +02:00
1 parent 7afb9758f4
commit 067190e1df
8 files changed
+334 -85

No files matched your search

@@ -717,4 +717,109 @@ describe('ServerUserDataStorage with other devices', () => {
});
expect(favorites(tabA)).toEqual({ p1: ['a', 'A1'], p2: ['b', 'B2'] });
});
it("keeps this tab's newer progress over a closed tab's older one", async () => {
const at = (seconds: number) => [
{
contentType: 'vod',
contentXtreamId: 1,
positionSeconds: seconds,
},
];
const server = fakeServer({
'xtream-playback-positions': { p1: at(10) },
});
const shared = memoryStorage();
const offline = () =>
Promise.reject(new ServerApiError(0, 'network', 'offline'));
const clock = jest.spyOn(Date, 'now').mockReturnValue(1_000);
const closed = new ServerUserDataStorage(
{ ...server.api, sendJson: jest.fn(offline) } as never,
shared,
jest.fn(),
'default',
memoryStorage()
);
await closed.hydrate();
closed.setItem(
'xtream-playback-positions',
JSON.stringify({ p1: at(20) })
);
await closed.flush();
// This tab starts while the server cannot be read: its edit waits.
let readable = false;
const tab = new ServerUserDataStorage(
{
...server.api,
getJson: jest.fn(() =>
readable ? server.api.getJson() : offline()
),
} as never,
shared,
jest.fn(),
'default',
memoryStorage()
);
await expect(tab.hydrate()).rejects.toBeInstanceOf(ServerApiError);
tab.setItem(
'xtream-playback-positions',
JSON.stringify({ p1: at(30) })
);
// Later the closed tab's record is stale and the server answers.
clock.mockReturnValue(1_000 + UNCONFIRMED_TTL_MS + 1);
readable = true;
await tab.hydrate();
await tab.flush();
await tab.flush();
clock.mockRestore();
expect(server.state['xtream-playback-positions']['p1']).toEqual(at(30));
});
it("keeps the last page load's saved writes when storage refuses the copy", 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', 'X'] }));
await before.flush();
const saved = () =>
[...local.values.keys()].filter((key) =>
key.startsWith('iptvnator-server:unsent-writes:')
);
expect(saved()).toHaveLength(1);
// Storage is full now: the reloaded page cannot save its copy.
const setItem = local.setItem;
local.setItem = (key: string, value: string) => {
if (key.startsWith('iptvnator-server:unsent-writes:')) {
throw new DOMException('full', 'QuotaExceededError');
}
setItem(key, value);
};
const reloaded = new ServerUserDataStorage(
offline as never,
local,
jest.fn(),
'default',
tabStore
);
expect(reloaded.hasUnsentWrites()).toBe(true);
expect(saved()).toHaveLength(1);
});
});