fix(server-storage): per-tab saved writes, memory overflow cache, snapshot aliases

- Unconfirmed writes and early edits are kept in the tab's own
  sessionStorage, so tabs never clear each other's; a shared registry of
  unconfirmed entries (one record per tab, ignored after ten minutes)
  keeps a re-read in one tab from replacing another tab's values.
- A cache value too big for localStorage stays readable from memory, so
  later edits are diffed against what the app reads.
- Typing legacy Xtream ids keeps each snapshot's bare key as an alias, so
  a reference not yet rewritten still resolves and a retry can type it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 08:33:32 +02:00
1 parent cedc468325
commit f49ad59549
9 files changed
+292 -74

No files matched your search

+12 -6
View File
@@ -201,8 +201,9 @@ settings and guards read them in every build.
`keepalive` flush when the page hides. Batches go out one after another
(the page-hide flush waits for a batch still travelling), a failed write,
including a refused `keepalive` one, stays queued for the next flush, and
the page-hide flush saves every unconfirmed write (queued or travelling)
under `iptvnator-server:unsent-writes:<instanceId>` for the next visit.
every unconfirmed write (queued or travelling) is saved in the tab's own
`sessionStorage` (`iptvnator-server:unsent-writes:<instanceId>`) and sent
first after a reload; tabs never clear each other's.
Cache and saved writes belong to one server database
(`iptvnator-server:cache-owner`); another one behind the same address
starts clean. Also,
@@ -212,9 +213,13 @@ settings and guards read them in every build.
its headers counts as offline. 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 too
(`iptvnator-server:deferred-edits:<instanceId>`), and queued writes are
saved as soon as they are queued, so a re-read in another tab keeps them. A re-read
instead of replacing the server's list. They are saved in the tab too
(`iptvnator-server:deferred-edits:<instanceId>`). A shared registry in
`localStorage` (`iptvnator-server:unconfirmed:<instanceId>`, one record
per tab, ignored after ten minutes) lets a re-read in one tab keep the
cache values another tab is still sending. A value too big for
`localStorage` stays readable from memory, so callers and the diff
baseline agree. A re-read
updates the cache; views already open show another device's changes on
their next load (no live push). Start-up waits
up to 3 s for the first read.
@@ -246,7 +251,8 @@ settings and guards read them in every build.
URL; older ones by a channel id that is random per import and is
translated through the URL; nothing is dropped), and the legacy Xtream maps follow under
the server id; each copy's bare legacy Xtream ids are typed from its own
snapshots first (`typeLegacyXtreamRefs`), then `combineUserData` unions
snapshots first (`typeLegacyXtreamRefs`; snapshots keep their bare key as
an alias), then `combineUserData` unions
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
@@ -16,6 +16,7 @@ describe('typeLegacyXtreamRefs', () => {
'xtream-favorites': ['live:42', 'movie:7', 99],
'xtream-recent-items': [{ id: 'live:42', viewedAt: 't' }],
'xtream-collection-items': {
'42': { xtream_id: 42, type: 'live', title: 'News' },
'live:42': { xtream_id: 42, type: 'live', title: 'News' },
'movie:7': { xtream_id: 7, type: 'movie' },
},
@@ -48,6 +49,8 @@ describe('typeLegacyXtreamRefs', () => {
browser['xtream-collection-items'],
])
).toEqual({
// The server's own bare alias wins its key; both typed keys stay.
'42': { xtream_id: 42, type: 'movie' },
'movie:42': { xtream_id: 42, type: 'movie' },
'live:42': { xtream_id: 42, type: 'live' },
});
@@ -10,7 +10,8 @@ const SNAPSHOTS = 'xtream-collection-items';
* repeats across live, movie and series. Before two copies are combined,
* each one's bare ids become typed keys (`live:42`) using that copy's own
* collection snapshots, the same evidence the PWA reads them with; ids
* without a snapshot stay as they are.
* without a snapshot stay as they are. Snapshots gain the typed key and
* keep the bare one.
*/
export function typeLegacyXtreamRefs(data: XtreamUserData): XtreamUserData {
const snapshots = isRecord(data[SNAPSHOTS]) ? data[SNAPSHOTS] : {};
@@ -45,12 +46,16 @@ export function typeLegacyXtreamRefs(data: XtreamUserData): XtreamUserData {
);
}
if (isRecord(data[SNAPSHOTS])) {
const rekeyed: Record<string, unknown> = {};
// The bare key stays as an alias: a reference not yet rewritten (a
// failed write, an older client) still finds its snapshot, and a
// retry can type it again.
const aliased: Record<string, unknown> = { ...snapshots };
for (const [key, snapshot] of Object.entries(snapshots)) {
rekeyed[String(/^\d+$/.test(key) ? typed(Number(key)) : key)] =
snapshot;
if (/^\d+$/.test(key)) {
aliased[String(typed(Number(key)))] = snapshot;
}
}
result[SNAPSHOTS] = rekeyed;
result[SNAPSHOTS] = aliased;
}
return result;
}
@@ -12,7 +12,10 @@ import {
SERVER_SIGN_IN_PATH,
ServerStorageStatusService,
} from '../server-storage-status.service';
import { createBrowserUserDataStorage } from '../user-data-storage';
import {
createBrowserSessionStorage,
createBrowserUserDataStorage,
} from '../user-data-storage';
import { ServerApiClient, type ServerApiError } from './server-api-client';
import { ServerPlaylistDocumentStore } from './server-playlist-document-store';
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
@@ -43,7 +46,8 @@ export class ServerStorageSession {
// Lazy: a browser that denies storage must not stop the app here.
createBrowserUserDataStorage(),
() => this.announceWriteFailure(),
this.config.instance.instanceId ?? undefined
this.config.instance.instanceId ?? undefined,
createBrowserSessionStorage()
);
constructor() {
@@ -0,0 +1,40 @@
import {
markUnconfirmed,
othersUnconfirmed,
UNCONFIRMED_TTL_MS,
} from './server-unsent-writes';
function memoryStorage() {
const values = new Map<string, string>();
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => void values.set(key, value),
removeItem: (key: string) => void values.delete(key),
};
}
describe('unconfirmed entries across tabs', () => {
it('lists other live tabs only and lets a tab clear just its record', () => {
const local = memoryStorage();
markUnconfirmed(local, 'db', 'a', ['xtream-favorites|p1'], 1_000);
markUnconfirmed(local, 'db', 'b', ['xtream-favorites|p2'], 1_000);
expect([...othersUnconfirmed(local, 'db', 'a', 2_000)]).toEqual([
'xtream-favorites|p2',
]);
markUnconfirmed(local, 'db', 'b', [], 3_000);
expect([...othersUnconfirmed(local, 'db', 'b', 3_000)]).toEqual([
'xtream-favorites|p1',
]);
});
it('ignores the record of a tab that is gone', () => {
const local = memoryStorage();
markUnconfirmed(local, 'db', 'a', ['xtream-favorites|p1'], 0);
expect(
othersUnconfirmed(local, 'db', 'b', UNCONFIRMED_TTL_MS + 1).size
).toBe(0);
});
});
@@ -16,6 +16,16 @@ export const UNSENT_WRITES_KEY = 'iptvnator-server:unsent-writes';
export const CACHE_OWNER_KEY = 'iptvnator-server:cache-owner';
const writesKey = (instanceId: string) => `${UNSENT_WRITES_KEY}:${instanceId}`;
/**
* Which entries each open tab has not had confirmed yet, so a re-read in
* one tab keeps the cache values another tab is still sending. A record
* older than `UNCONFIRMED_TTL_MS` belongs to a tab that is gone.
*/
export const UNCONFIRMED_KEY = 'iptvnator-server:unconfirmed';
export const UNCONFIRMED_TTL_MS = 10 * 60 * 1000;
const unconfirmedKey = (instanceId: string) =>
`${UNCONFIRMED_KEY}:${instanceId}`;
/** Edits made before the first read of a visit, kept as before → after. */
export const DEFERRED_EDITS_KEY = 'iptvnator-server:deferred-edits';
const deferredKey = (instanceId: string) =>
@@ -36,6 +46,7 @@ export function claimCache(local: UserDataStorage, instanceId: string): void {
local.removeItem(`iptvnator-server:${namespace}`);
}
if (owner) {
local.removeItem(unconfirmedKey(owner));
local.removeItem(writesKey(owner));
local.removeItem(deferredKey(owner));
}
@@ -194,3 +205,69 @@ export function saveDeferredEdits(
// Storage unavailable or full: the edits stay in memory only.
}
}
type UnconfirmedRegistry = Record<string, { at: number; keys: string[] }>;
function readRegistry(
local: UserDataStorage,
instanceId: string
): UnconfirmedRegistry {
try {
const parsed: unknown = JSON.parse(
local.getItem(unconfirmedKey(instanceId)) ?? '{}'
);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as UnconfirmedRegistry)
: {};
} catch {
return {};
}
}
/** Replaces only this tab's record. */
export function markUnconfirmed(
local: UserDataStorage,
instanceId: string,
tabId: string,
keys: readonly string[],
now = Date.now()
): void {
const registry = readRegistry(local, instanceId);
if (keys.length === 0) {
if (!(tabId in registry)) {
return;
}
delete registry[tabId];
} else {
registry[tabId] = { at: now, keys: [...keys] };
}
try {
local.setItem(unconfirmedKey(instanceId), JSON.stringify(registry));
} catch {
// Storage full: re-reads in other tabs lose this protection only.
}
}
/** Entry keys other live tabs have not had confirmed yet. */
export function othersUnconfirmed(
local: UserDataStorage,
instanceId: string,
tabId: string,
now = Date.now()
): Set<string> {
const keys = new Set<string>();
for (const [owner, record] of Object.entries(
readRegistry(local, instanceId)
)) {
if (
owner !== tabId &&
Array.isArray(record?.keys) &&
now - Number(record.at) < UNCONFIRMED_TTL_MS
) {
for (const key of record.keys) {
keys.add(String(key));
}
}
}
return keys;
}
@@ -408,7 +408,7 @@ describe('ServerUserDataStorage', () => {
expect(api.sendJson).not.toHaveBeenCalled();
});
it('keeps cache and baseline together when the cache is full', async () => {
it('keeps a value that does not fit the cache readable and consistent', async () => {
const { api, local, storage } = setup();
await storage.hydrate();
// The server now has a second playlist the cache cannot take.
@@ -425,20 +425,25 @@ describe('ServerUserDataStorage', () => {
setItem(key, value);
};
await storage.hydrate();
local.setItem = setItem;
// The app reads, edits and writes back, as PwaCollectionStorage does.
const current = JSON.parse(storage.getItem('xtream-favorites') ?? '');
expect(current).toEqual({ p1: ['live:1'], p2: ['live:2'] });
storage.setItem(
'xtream-favorites',
JSON.stringify({ p1: ['live:1', 'live:3'] })
JSON.stringify({ ...current, p1: ['live:1', 'live:3'] })
);
await storage.flush();
local.setItem = setItem;
expect(api.request).not.toHaveBeenCalled();
expect(api.sendJson).toHaveBeenCalledWith(
'PUT',
'/user-state/xtream-favorites/p1',
{ value: ['live:1', 'live:3'] }
);
expect(api.sendJson.mock.calls).toEqual([
[
'PUT',
'/user-state/xtream-favorites/p1',
{ value: ['live:1', 'live:3'] },
],
]);
});
it('keeps unconfirmed writes across a reload and sends them first', async () => {
@@ -623,40 +628,70 @@ describe('ServerUserDataStorage', () => {
});
it("does not replace another tab's unconfirmed edit on a re-read", async () => {
// A server that keeps what it is sent.
const state: Record<string, Record<string, unknown>> = {
'xtream-favorites': { p1: ['live:1'] },
const shared = memoryStorage();
const api = {
getJson: jest.fn().mockResolvedValue({
entries: { 'xtream-favorites': { p1: ['live:1'] } },
}),
sendJson: jest.fn().mockResolvedValue(undefined),
request: jest.fn().mockResolvedValue(undefined),
sendKeepalive: jest.fn().mockResolvedValue(true),
};
const tabA = setup();
tabA.api.getJson.mockImplementation(async () => ({
entries: structuredClone(state),
}));
tabA.api.sendJson.mockImplementation(
async (_method: string, path: string, body: { value: unknown }) => {
const [, , namespace, playlistId] = path.split('/');
state[namespace] = {
...state[namespace],
[playlistId]: body.value,
};
}
);
await tabA.storage.hydrate();
tabA.storage.setItem(
const tab = () =>
new ServerUserDataStorage(
api as never,
shared,
jest.fn(),
'default',
memoryStorage()
);
const tabA = tab();
const tabB = tab();
await tabA.hydrate();
await tabB.hydrate();
// Tab A's edit is queued, not confirmed; tab B re-reads.
tabA.setItem(
'xtream-favorites',
JSON.stringify({ p1: ['live:1', 'live:7'] })
);
// Tab A's write is queued, not sent yet; tab B starts and re-reads.
const tabB = new ServerUserDataStorage(
tabA.api as never,
tabA.local,
jest.fn()
);
await tabB.hydrate();
expect(JSON.parse(tabB.getItem('xtream-favorites') ?? '')).toEqual({
p1: ['live:1', 'live:7'],
});
expect(state['xtream-favorites']['p1']).toEqual(['live:1', 'live:7']);
// Tab B never takes over tab A's saved writes.
expect(api.sendJson).not.toHaveBeenCalled();
});
it('keeps server favorites through a cache-full first read and a later edit', async () => {
const { api, local, storage } = setup();
local.setItem('iptvnator-server:xtream-favorites', '{"p1":["A"]}');
storage.setItem('xtream-favorites', '{"p1":["A","B"]}');
api.getJson.mockResolvedValueOnce({
entries: { 'xtream-favorites': { p1: ['S'] } },
});
const setItem = local.setItem;
local.setItem = (key: string, value: string) => {
if (key === 'iptvnator-server:xtream-favorites') {
throw new DOMException('full', 'QuotaExceededError');
}
setItem(key, value);
};
await storage.hydrate();
const current = JSON.parse(storage.getItem('xtream-favorites') ?? '');
storage.setItem(
'xtream-favorites',
JSON.stringify({ p1: [...current.p1, 'C'] })
);
await storage.flush();
local.setItem = setItem;
expect(api.sendJson).toHaveBeenLastCalledWith(
'PUT',
'/user-state/xtream-favorites/p1',
{ value: ['S', 'B', 'C'] }
);
});
});
@@ -16,7 +16,9 @@ import {
import { type DeferredEdit, refreshedNamespace } from './server-user-data-edit';
import {
claimCache,
markUnconfirmed,
newestWrites,
othersUnconfirmed,
readDeferredEdits,
readUnsentWrites,
saveDeferredEdits,
@@ -58,6 +60,10 @@ export class ServerUserDataStorage implements UserDataStorage {
*/
private readonly deferred = new Map<string, DeferredEdit>();
private hydrated = false;
/** This tab, in the shared registry of unconfirmed entries. */
private readonly tabId = Math.random().toString(36).slice(2);
/** Cache values that did not fit into browser storage. */
private readonly overflow = new Map<ServerUserStateNamespace, string>();
/** Re-reads run one at a time, so an older one never lands last. */
private hydration: Promise<void> = Promise.resolve();
/** Edit counter per `namespace|playlistId`, for hydrate to keep them. */
@@ -69,16 +75,22 @@ export class ServerUserDataStorage implements UserDataStorage {
private readonly local: UserDataStorage,
private readonly onWriteFailure: (error: unknown) => void,
/** Scopes the cache and saved writes to one server database. */
private readonly instanceId = 'default'
private readonly instanceId = 'default',
/**
* Where this tab keeps what the server has not confirmed: its own
* (`sessionStorage` in the app), so tabs never clear each other's,
* and a reload of the tab finds it again.
*/
private readonly tabStore: UserDataStorage = local
) {
claimCache(local, instanceId);
// Writes a previous visit could not confirm go out with the first
// flush, and the first read keeps them.
for (const write of readUnsentWrites(local, instanceId)) {
for (const write of readUnsentWrites(tabStore, instanceId)) {
this.queue(write.namespace, write.playlistId, write.value);
}
this.unsentSaved = this.pending.size > 0;
for (const edit of readDeferredEdits(local, instanceId)) {
for (const edit of readDeferredEdits(tabStore, instanceId)) {
this.deferred.set(`${edit.namespace}|${edit.playlistId}`, edit);
}
}
@@ -94,7 +106,7 @@ export class ServerUserDataStorage implements UserDataStorage {
getItem(key: string): string | null {
return this.isSynced(key)
? this.local.getItem(SERVER_CACHE_PREFIX + key)
? this.readCache(key)
: this.local.getItem(key);
}
@@ -104,7 +116,7 @@ export class ServerUserDataStorage implements UserDataStorage {
return;
}
const before = this.baseline(key);
this.local.setItem(SERVER_CACHE_PREFIX + key, value);
this.writeCache(key, value);
this.queueChanges(key, parseMap(value), before);
}
@@ -114,7 +126,7 @@ export class ServerUserDataStorage implements UserDataStorage {
return;
}
const before = this.baseline(key);
this.local.removeItem(SERVER_CACHE_PREFIX + key);
this.writeCache(key, null);
this.queueChanges(key, {}, before);
}
@@ -148,8 +160,12 @@ export class ServerUserDataStorage implements UserDataStorage {
}
}
// Another tab's unconfirmed writes are in the shared cache too.
for (const write of readUnsentWrites(this.local, this.instanceId)) {
unsaved.add(`${write.namespace}|${write.playlistId}`);
for (const key of othersUnconfirmed(
this.local,
this.instanceId,
this.tabId
)) {
unsaved.add(key);
}
const deferred = [...this.deferred.values()];
const refreshed = new Map<string, Record<string, unknown>>();
@@ -163,23 +179,14 @@ export class ServerUserDataStorage implements UserDataStorage {
}
const value = refreshedNamespace(
entries[namespace] ?? {},
parseMap(
this.local.getItem(SERVER_CACHE_PREFIX + namespace) ?? '{}'
),
parseMap(this.readCache(namespace) ?? '{}'),
keepCached,
deferred.filter((edit) => edit.namespace === namespace)
);
refreshed.set(namespace, value);
try {
this.local.setItem(
SERVER_CACHE_PREFIX + namespace,
JSON.stringify(value)
);
} catch {
// Storage full: keep the old cache and the baseline it was
// diffed against together, or an edit would read as deletes.
continue;
}
// A value too big for browser storage stays readable in memory,
// so callers and the diff baseline see the same map.
this.writeCache(namespace, JSON.stringify(value));
this.synced.set(namespace, value);
}
for (const [key, edit] of this.edits) {
@@ -202,7 +209,7 @@ export class ServerUserDataStorage implements UserDataStorage {
refreshed.get(edit.namespace)?.[edit.playlistId]
);
}
saveDeferredEdits(this.local, this.instanceId, []);
saveDeferredEdits(this.tabStore, this.instanceId, []);
this.scheduleFlush();
}
}
@@ -266,6 +273,30 @@ export class ServerUserDataStorage implements UserDataStorage {
);
}
private readCache(namespace: ServerUserStateNamespace): string | null {
return (
this.overflow.get(namespace) ??
this.local.getItem(SERVER_CACHE_PREFIX + namespace)
);
}
/** `null` empties the entry. Browser storage first, memory if full. */
private writeCache(
namespace: ServerUserStateNamespace,
value: string | null
): void {
try {
if (value === null) {
this.local.removeItem(SERVER_CACHE_PREFIX + namespace);
} else {
this.local.setItem(SERVER_CACHE_PREFIX + namespace, value);
}
this.overflow.delete(namespace);
} catch {
this.overflow.set(namespace, value ?? '{}');
}
}
private isSynced(key: string): key is ServerUserStateNamespace {
return isServerUserStateNamespace(key);
}
@@ -279,9 +310,7 @@ export class ServerUserDataStorage implements UserDataStorage {
): Record<string, unknown> {
return (
this.synced.get(namespace) ??
parseMap(
this.local.getItem(SERVER_CACHE_PREFIX + namespace) ?? '{}'
)
parseMap(this.readCache(namespace) ?? '{}')
);
}
@@ -304,9 +333,10 @@ export class ServerUserDataStorage implements UserDataStorage {
before: earlier ? earlier.before : previous[playlistId],
after: value,
});
saveDeferredEdits(this.local, this.instanceId, [
saveDeferredEdits(this.tabStore, this.instanceId, [
...this.deferred.values(),
]);
this.publishUnconfirmed();
};
for (const [playlistId, value] of Object.entries(next)) {
if (
@@ -424,7 +454,16 @@ export class ServerUserDataStorage implements UserDataStorage {
if (writes.length === 0 && !this.unsentSaved) {
return;
}
saveUnsentWrites(this.local, this.instanceId, writes);
saveUnsentWrites(this.tabStore, this.instanceId, writes);
this.unsentSaved = writes.length > 0;
this.publishUnconfirmed();
}
private publishUnconfirmed(): void {
markUnconfirmed(this.local, this.instanceId, this.tabId, [
...this.pending.keys(),
...this.inFlight.keys(),
...this.deferred.keys(),
]);
}
}
@@ -20,6 +20,15 @@ export function createBrowserUserDataStorage(): UserDataStorage {
};
}
/** The same lazy adapter over `sessionStorage`: one per tab, kept on reload. */
export function createBrowserSessionStorage(): UserDataStorage {
return {
getItem: (key) => globalThis.sessionStorage.getItem(key),
setItem: (key, value) => globalThis.sessionStorage.setItem(key, value),
removeItem: (key) => globalThis.sessionStorage.removeItem(key),
};
}
/**
* Browser persistence for PWA user data such as Xtream favorites, recently
* viewed items and playback positions. Defaults to `localStorage`; the