mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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>
94 lines
2.9 KiB
TypeScript
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));
|
|
}
|
|
}
|