Files
iptvnator/libs/services/src/lib/server-storage/server-user-data-cache.ts
T
4grayandClaude Opus 5.5 fdaa2b5aa0 fix(server-storage): per-instance cache, idle tabs adopt closed ones, backups wait for the first read
The user-data cache is keyed by server instance
(`iptvnator-server:cache:<instanceId>:<namespace>`): a tab still open on
the old backend after BACKEND_URL changed no longer writes over the new
server's cache, which the new tab's next edit would have sent there.
claimCache also removes the old owner's keys and the unscoped keys of
earlier builds.

touch(), which runs every 30 s, adopts closed tabs' saved writes once
this tab has read: a tab with nothing to sync never re-reads, so they
waited until a tab switch or reload.

UserDataStorage gains an optional ready(); the server storage resolves
it after its first read and rejects while that fails. A PWA backup
export awaits it, so a fresh browser whose /user-state read is slow or
failed cannot export empty favorites, history and positions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:29:36 +02:00

94 lines
2.9 KiB
TypeScript

import {
SERVER_USER_STATE_NAMESPACES,
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:cache';
/**
* One namespace's cache of one server instance: a tab still open on
* another server (a changed backend URL) never writes over it.
*/
export function cacheKey(
instanceId: string,
namespace: ServerUserStateNamespace
): string {
return `${SERVER_CACHE_PREFIX}:${instanceId}:${namespace}`;
}
/**
* 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,
private readonly instanceId: string
) {}
read(namespace: ServerUserStateNamespace): string | null {
const kept = this.overflow.get(namespace);
if (kept !== undefined) {
return kept;
}
try {
return this.local.getItem(cacheKey(this.instanceId, namespace));
} catch {
return null;
}
}
/** `null` empties the namespace. */
write(namespace: ServerUserStateNamespace, value: string | null): void {
try {
const key = cacheKey(this.instanceId, namespace);
if (value === null) {
this.local.removeItem(key);
} else {
this.local.setItem(key, value);
}
this.overflow.delete(namespace);
} catch {
this.overflow.set(namespace, value ?? '{}');
}
}
map(namespace: ServerUserStateNamespace): Record<string, unknown> {
return parseMap(this.read(namespace) ?? '{}');
}
/** After a delete: one playlist's entries, without an id all of them. */
forget(playlistId?: string): void {
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
const map = this.map(namespace);
if (playlistId === undefined) {
this.write(namespace, null);
} else if (playlistId in map) {
delete map[playlistId];
this.write(namespace, JSON.stringify(map));
}
}
}
/** `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));
}
}