fix(server-storage): apply a caller's edits to the cache as it is now

ServerUserDataStorage remembers the map getItem handed out. When the
cache changed since (another tab saved, a re-read), a write applies only
the entries the caller changed to the current cache, so a map read
before another tab's save no longer reads as deleting that save. The
cache moves into UserDataCache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 20:45:52 +02:00
1 parent 2a93100ec4
commit 70fabb1424
4 files changed
+182 -82

No files matched your search

+3 -1
View File
@@ -203,7 +203,9 @@ settings and guards read them in every build.
the four synced namespaces are cached under the `iptvnator-server:` key
prefix (the legacy unprefixed keys are never written in server mode) and
each change sends only the playlist entries that differ, debounced, with a
`keepalive` flush when the page hides. Batches go out one after another
`keepalive` flush when the page hides. A change is compared with the map
the caller read: when the cache changed since (another tab, a re-read),
only the entries the caller changed are applied to it. 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
every unconfirmed write (queued or travelling) is saved in `localStorage`
@@ -0,0 +1,62 @@
import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces';
import type { UserDataStorage } from '../user-data-storage';
import { parseMap } from './server-user-data-entries';
/** Server-mode cache keys; the legacy unprefixed keys are never touched. */
export const SERVER_CACHE_PREFIX = 'iptvnator-server:';
/**
* The synced namespaces as this browser shows them: in browser storage,
* shared by the tabs, or in memory when it is full or refuses access, so
* callers and the diff baseline always read the same map.
*/
export class UserDataCache {
private readonly overflow = new Map<ServerUserStateNamespace, string>();
constructor(private readonly local: UserDataStorage) {}
read(namespace: ServerUserStateNamespace): string | null {
const kept = this.overflow.get(namespace);
if (kept !== undefined) {
return kept;
}
try {
return this.local.getItem(SERVER_CACHE_PREFIX + namespace);
} catch {
return null;
}
}
/** `null` empties the namespace. */
write(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 ?? '{}');
}
}
map(namespace: ServerUserStateNamespace): Record<string, unknown> {
return parseMap(this.read(namespace) ?? '{}');
}
/** `undefined` removes the playlist's entry. */
setEntry(
namespace: ServerUserStateNamespace,
playlistId: string,
value: unknown
): void {
const map = this.map(namespace);
if (value === undefined) {
delete map[playlistId];
} else {
map[playlistId] = value;
}
this.write(namespace, JSON.stringify(map));
}
}
@@ -12,7 +12,9 @@ import {
splitEditKey,
UNKNOWN_TAG,
} from './server-user-data-entries';
import { UserDataCache } from './server-user-data-cache';
import {
applyEdit,
type DeferredEdit,
refreshedNamespace,
replaySteps,
@@ -31,8 +33,7 @@ import {
swapPage,
} from './server-unsent-writes';
/** Server-mode cache keys; the legacy unprefixed keys are never touched. */
export const SERVER_CACHE_PREFIX = 'iptvnator-server:';
export { SERVER_CACHE_PREFIX } from './server-user-data-cache';
type ServerApi = Pick<
ServerApiClient,
@@ -59,8 +60,16 @@ export class ServerUserDataStorage implements UserDataStorage {
private hydrated = false;
/** This page load: names its saved writes and its registry record. */
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>();
private readonly cache: UserDataCache;
/**
* What `getItem` last handed out (or a write left) per namespace: the
* map a caller edits, so its write is compared with that, not with
* changes another tab or a re-read made to the cache since.
*/
private readonly handedOut = new Map<
ServerUserStateNamespace,
string | null
>();
/** Re-reads run one at a time, so an older one never lands last. */
private hydration: Promise<void> = Promise.resolve();
@@ -76,10 +85,11 @@ export class ServerUserDataStorage implements UserDataStorage {
*/
tabStore: UserDataStorage = local
) {
this.cache = new UserDataCache(local);
this.writes = new UserDataWriteQueue(api, {
changed: () => this.saveUnsent(),
show: (namespace, playlistId, value) =>
this.showEntry(namespace, playlistId, value),
this.cache.setEntry(namespace, playlistId, value),
writeFailed: onWriteFailure,
});
claimCache(local, instanceId);
@@ -142,10 +152,10 @@ export class ServerUserDataStorage implements UserDataStorage {
);
for (const { namespace, playlistId, steps } of edits) {
const value = replaySteps(
this.baseline(namespace)[playlistId],
this.cache.map(namespace)[playlistId],
steps
);
this.showEntry(namespace, playlistId, value);
this.cache.setEntry(namespace, playlistId, value);
// Queued even when the cache shows it already.
this.writes.adopt(
namespace,
@@ -174,29 +184,52 @@ export class ServerUserDataStorage implements UserDataStorage {
}
getItem(key: string): string | null {
return this.isSynced(key)
? this.readCache(key)
: this.local.getItem(key);
if (!this.isSynced(key)) {
return this.local.getItem(key);
}
const value = this.cache.read(key);
this.handedOut.set(key, value);
return value;
}
setItem(key: string, value: string): void {
if (!this.isSynced(key)) {
if (this.isSynced(key)) {
this.write(key, value);
} else {
this.local.setItem(key, value);
return;
}
const before = this.baseline(key);
this.writeCache(key, value);
this.queueChanges(key, parseMap(value), before);
}
removeItem(key: string): void {
if (!this.isSynced(key)) {
if (this.isSynced(key)) {
this.write(key, null);
} else {
this.local.removeItem(key);
return;
}
const before = this.baseline(key);
this.writeCache(key, null);
this.queueChanges(key, {}, before);
}
/**
* A caller's new map. When the cache changed since the caller read it
* (another tab, a re-read), only the entries it changed are applied to
* the cache as it is now; the rest stays.
*/
private write(
namespace: ServerUserStateNamespace,
value: string | null
): void {
const current = this.cache.read(namespace);
const before = parseMap(current ?? '{}');
const read = this.handedOut.has(namespace)
? (this.handedOut.get(namespace) ?? null)
: current;
let next = parseMap(value ?? '{}');
if (read !== current) {
next = mergeEdits(before, parseMap(read ?? '{}'), next);
value = JSON.stringify(next);
}
this.cache.write(namespace, value);
this.handedOut.set(namespace, value);
this.queueChanges(namespace, next, before);
}
/**
@@ -258,12 +291,12 @@ export class ServerUserDataStorage implements UserDataStorage {
this.saveDeferred();
this.writes.drop(matches, playlistId);
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
const map = this.baseline(namespace);
const map = this.cache.map(namespace);
if (playlistId === undefined) {
this.writeCache(namespace, null);
this.cache.write(namespace, null);
} else if (playlistId in map) {
delete map[playlistId];
this.writeCache(namespace, JSON.stringify(map));
this.cache.write(namespace, JSON.stringify(map));
}
}
}
@@ -295,14 +328,14 @@ export class ServerUserDataStorage implements UserDataStorage {
}
const value = refreshedNamespace(
entries[namespace] ?? {},
this.baseline(namespace),
this.cache.map(namespace),
keepCached,
deferred.filter((edit) => edit.namespace === namespace)
);
refreshed.set(namespace, value);
// 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.cache.write(namespace, JSON.stringify(value));
if (tags) {
// A kept value is not what the server's tag describes: its
// next write must conflict and be replayed on the server's.
@@ -332,66 +365,10 @@ export class ServerUserDataStorage implements UserDataStorage {
this.adoptClosedTabs();
}
/** Memory first; browser storage that refuses access reads as empty. */
private readCache(namespace: ServerUserStateNamespace): string | null {
const kept = this.overflow.get(namespace);
if (kept !== undefined) {
return kept;
}
try {
return this.local.getItem(SERVER_CACHE_PREFIX + namespace);
} catch {
return null;
}
}
/** `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 ?? '{}');
}
}
/** A conflict replay settled what the entry holds. */
private showEntry(
namespace: ServerUserStateNamespace,
playlistId: string,
value: unknown
): void {
const map = this.baseline(namespace);
if (value === undefined) {
delete map[playlistId];
} else {
map[playlistId] = value;
}
this.writeCache(namespace, JSON.stringify(map));
}
private isSynced(key: string): key is ServerUserStateNamespace {
return isServerUserStateNamespace(key);
}
/**
* What a write is compared against: the map the caller read, so an edit
* sends only its own entries, never another tab's changes in the shared
* cache.
*/
private baseline(
namespace: ServerUserStateNamespace
): Record<string, unknown> {
return parseMap(this.readCache(namespace) ?? '{}');
}
private queueChanges(
namespace: ServerUserStateNamespace,
next: Record<string, unknown>,
@@ -465,3 +442,23 @@ export class ServerUserDataStorage implements UserDataStorage {
]);
}
}
/** The entries `next` changed against `read`, applied to `current`. */
function mergeEdits(
current: Record<string, unknown>,
read: Record<string, unknown>,
next: Record<string, unknown>
): Record<string, unknown> {
const merged = { ...current };
for (const id of new Set([...Object.keys(read), ...Object.keys(next)])) {
if (JSON.stringify(read[id]) !== JSON.stringify(next[id])) {
const value = applyEdit(current[id], read[id], next[id]);
if (value === undefined) {
delete merged[id];
} else {
merged[id] = value;
}
}
}
return merged;
}
@@ -678,4 +678,43 @@ describe('ServerUserDataStorage with other devices', () => {
[...(server.state['xtream-favorites']['p1'] as string[])].sort()
).toEqual(['S', 'X', 'Y']);
});
it('applies only the edits of a caller that read before another tab saved', async () => {
const server = fakeServer({
'xtream-favorites': { p1: ['a'], p2: ['b'] },
});
const shared = memoryStorage();
const tab = () =>
new ServerUserDataStorage(
server.api as never,
shared,
jest.fn(),
'default',
memoryStorage()
);
const tabA = tab();
const tabB = tab();
await tabA.hydrate();
await tabB.hydrate();
const readByA = favorites(tabA);
tabB.setItem(
'xtream-favorites',
JSON.stringify({ ...favorites(tabB), p2: ['b', 'B2'] })
);
await tabB.flush();
// Tab A writes back the map it read before tab B saved.
tabA.setItem(
'xtream-favorites',
JSON.stringify({ ...readByA, p1: ['a', 'A1'] })
);
await tabA.flush();
await tabA.flush();
expect(server.state['xtream-favorites']).toEqual({
p1: ['a', 'A1'],
p2: ['b', 'B2'],
});
expect(favorites(tabA)).toEqual({ p1: ['a', 'A1'], p2: ['b', 'B2'] });
});
});