mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
- Unconfirmed writes and early edits are saved in localStorage per page load; the tab remembers its page load in sessionStorage, so a reload takes them over, and another tab replays a closed tab's writes once its record is stale. Live tabs renew their record every 30 s. - An edit made on another tab's unsent change, and an entry a re-read keeps from the cache, are sent on a version no entry has, so the write conflicts and is replayed instead of carrying another tab's change past a later removal. - Moving browser data counts the parental-lock step as failed while the lock store cannot be read, so the move stays on offer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
361 lines
11 KiB
TypeScript
361 lines
11 KiB
TypeScript
import {
|
|
isServerUserStateNamespace,
|
|
SERVER_USER_STATE_NAMESPACES,
|
|
type ServerUserStateNamespace,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import type { UserDataStorage } from '../user-data-storage';
|
|
|
|
/**
|
|
* User-data writes the server has not confirmed, kept in `localStorage`
|
|
* per page load: a failed write, or a page-hide write whose answer the page
|
|
* did not live to see, is sent again after a reload (by the same tab) or
|
|
* after the tab closed (by another, see `orphanedPages`), instead of being
|
|
* replaced by the server's older value.
|
|
*/
|
|
export const UNSENT_WRITES_KEY = 'iptvnator-server:unsent-writes';
|
|
/** Which server database the cached user data belongs to. */
|
|
export const CACHE_OWNER_KEY = 'iptvnator-server:cache-owner';
|
|
/** The tab's current page load, in the tab's own storage. */
|
|
export const PAGE_KEY = 'iptvnator-server:page';
|
|
|
|
const writesKey = (instanceId: string, page: string) =>
|
|
`${UNSENT_WRITES_KEY}:${instanceId}:${page}`;
|
|
/**
|
|
* Which entries each page load has not had confirmed yet, so a re-read in
|
|
* one tab keeps the cache values another tab is still sending. A live tab
|
|
* renews its record; one older than `UNCONFIRMED_TTL_MS` belongs to a tab
|
|
* that is gone. The records also list whose saved writes exist.
|
|
*/
|
|
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, page: string) =>
|
|
`${DEFERRED_EDITS_KEY}:${instanceId}:${page}`;
|
|
|
|
/**
|
|
* Another database behind this address (a new volume, another backend):
|
|
* its cache and unsent writes belong to the old one and must not be sent
|
|
* to or shown for this one.
|
|
*/
|
|
export function claimCache(local: UserDataStorage, instanceId: string): void {
|
|
try {
|
|
const owner = local.getItem(CACHE_OWNER_KEY);
|
|
if (owner === instanceId) {
|
|
return;
|
|
}
|
|
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
|
|
local.removeItem(`iptvnator-server:${namespace}`);
|
|
}
|
|
if (owner) {
|
|
for (const page of Object.keys(readRegistry(local, owner))) {
|
|
local.removeItem(writesKey(owner, page));
|
|
local.removeItem(deferredKey(owner, page));
|
|
}
|
|
local.removeItem(unconfirmedKey(owner));
|
|
}
|
|
local.setItem(CACHE_OWNER_KEY, instanceId);
|
|
} catch {
|
|
// Storage unavailable: nothing cached to mix up.
|
|
}
|
|
}
|
|
|
|
export interface UnsentWrite {
|
|
readonly namespace: ServerUserStateNamespace;
|
|
readonly playlistId: string;
|
|
/** `undefined` deletes the entry. */
|
|
readonly value: unknown;
|
|
/** The entry the edit was made against, to replay it on a conflict. */
|
|
readonly before?: unknown;
|
|
/** The server version it is based on (see `PendingEntry.tag`). */
|
|
readonly tag?: string | null;
|
|
/** A write sent before it, maybe saved (see `PendingEntry.prior`). */
|
|
readonly prior?: { readonly before: unknown; readonly after: unknown };
|
|
}
|
|
|
|
interface StoredWrite {
|
|
readonly namespace: string;
|
|
readonly playlistId: string;
|
|
readonly value?: unknown;
|
|
readonly remove?: true;
|
|
readonly before?: unknown;
|
|
readonly tag?: string | null;
|
|
/** JSON drops an undefined side: absent reads back as undefined. */
|
|
readonly prior?: { readonly before?: unknown; readonly after?: unknown };
|
|
}
|
|
|
|
/**
|
|
* Records `page` as the tab's current page load and returns the one
|
|
* before it: a reload, whose saved writes this page takes over.
|
|
*/
|
|
export function swapPage(
|
|
tabStore: UserDataStorage,
|
|
instanceId: string,
|
|
page: string
|
|
): string | null {
|
|
try {
|
|
const previous = tabStore.getItem(`${PAGE_KEY}:${instanceId}`);
|
|
tabStore.setItem(`${PAGE_KEY}:${instanceId}`, page);
|
|
return previous;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Page loads whose record went stale: closed tabs nobody sends for. */
|
|
export function orphanedPages(
|
|
local: UserDataStorage,
|
|
instanceId: string,
|
|
page: string,
|
|
now = Date.now()
|
|
): string[] {
|
|
return Object.entries(readRegistry(local, instanceId))
|
|
.filter(
|
|
([owner, record]) =>
|
|
owner !== page &&
|
|
!(now - Number(record?.at) < UNCONFIRMED_TTL_MS)
|
|
)
|
|
.map(([owner]) => owner);
|
|
}
|
|
|
|
/** Forgets a page load's saved writes, early edits and record. */
|
|
export function removePage(
|
|
local: UserDataStorage,
|
|
instanceId: string,
|
|
page: string
|
|
): void {
|
|
try {
|
|
local.removeItem(writesKey(instanceId, page));
|
|
local.removeItem(deferredKey(instanceId, page));
|
|
} catch {
|
|
// Storage unavailable: nothing was saved there either.
|
|
}
|
|
markUnconfirmed(local, instanceId, page, []);
|
|
}
|
|
|
|
export function readUnsentWrites(
|
|
local: UserDataStorage,
|
|
instanceId: string,
|
|
page: string
|
|
): UnsentWrite[] {
|
|
let stored: unknown;
|
|
try {
|
|
stored = JSON.parse(local.getItem(writesKey(instanceId, page)) ?? '[]');
|
|
} catch {
|
|
return [];
|
|
}
|
|
const writes: UnsentWrite[] = [];
|
|
for (const write of Array.isArray(stored)
|
|
? (stored as StoredWrite[])
|
|
: []) {
|
|
if (
|
|
write &&
|
|
isServerUserStateNamespace(write.namespace) &&
|
|
typeof write.playlistId === 'string'
|
|
) {
|
|
writes.push({
|
|
namespace: write.namespace,
|
|
playlistId: write.playlistId,
|
|
value: write.remove ? undefined : write.value,
|
|
before: write.before,
|
|
tag: write.tag,
|
|
prior:
|
|
write.prior && typeof write.prior === 'object'
|
|
? {
|
|
before: write.prior.before,
|
|
after: write.prior.after,
|
|
}
|
|
: undefined,
|
|
});
|
|
}
|
|
}
|
|
return writes;
|
|
}
|
|
|
|
export function saveUnsentWrites(
|
|
local: UserDataStorage,
|
|
instanceId: string,
|
|
page: string,
|
|
writes: readonly UnsentWrite[]
|
|
): void {
|
|
try {
|
|
if (writes.length === 0) {
|
|
local.removeItem(writesKey(instanceId, page));
|
|
return;
|
|
}
|
|
const stored: StoredWrite[] = writes.map(
|
|
({ namespace, playlistId, value, before, tag, prior }) => ({
|
|
namespace,
|
|
playlistId,
|
|
...(value === undefined ? { remove: true } : { value }),
|
|
...(before === undefined ? {} : { before }),
|
|
...(tag === undefined ? {} : { tag }),
|
|
...(prior === undefined
|
|
? {}
|
|
: { prior: { before: prior.before, after: prior.after } }),
|
|
})
|
|
);
|
|
local.setItem(writesKey(instanceId, page), JSON.stringify(stored));
|
|
} catch {
|
|
// Storage unavailable or full: the queue stays in memory only.
|
|
}
|
|
}
|
|
|
|
/** One write per entry, the newest by edit number. */
|
|
export function newestWrites<T extends UnsentWrite & { readonly edit: number }>(
|
|
writes: readonly T[]
|
|
): T[] {
|
|
const newest = new Map<string, T>();
|
|
for (const write of writes) {
|
|
const key = `${write.namespace}|${write.playlistId}`;
|
|
const known = newest.get(key);
|
|
if (!known || write.edit > known.edit) {
|
|
newest.set(key, write);
|
|
}
|
|
}
|
|
return [...newest.values()];
|
|
}
|
|
|
|
export interface SavedDeferredEdit {
|
|
readonly namespace: ServerUserStateNamespace;
|
|
readonly playlistId: string;
|
|
readonly before: unknown;
|
|
readonly after: unknown;
|
|
}
|
|
|
|
export function readDeferredEdits(
|
|
local: UserDataStorage,
|
|
instanceId: string,
|
|
page: string
|
|
): SavedDeferredEdit[] {
|
|
let stored: unknown;
|
|
try {
|
|
stored = JSON.parse(
|
|
local.getItem(deferredKey(instanceId, page)) ?? '[]'
|
|
);
|
|
} catch {
|
|
return [];
|
|
}
|
|
const edits: SavedDeferredEdit[] = [];
|
|
for (const edit of Array.isArray(stored)
|
|
? (stored as Record<string, unknown>[])
|
|
: []) {
|
|
const namespace = edit?.['namespace'];
|
|
const playlistId = edit?.['playlistId'];
|
|
if (
|
|
typeof namespace === 'string' &&
|
|
isServerUserStateNamespace(namespace) &&
|
|
typeof playlistId === 'string'
|
|
) {
|
|
// JSON has no undefined: absent sides are flagged.
|
|
edits.push({
|
|
namespace,
|
|
playlistId,
|
|
before: edit['noBefore'] ? undefined : edit['before'],
|
|
after: edit['noAfter'] ? undefined : edit['after'],
|
|
});
|
|
}
|
|
}
|
|
return edits;
|
|
}
|
|
|
|
export function saveDeferredEdits(
|
|
local: UserDataStorage,
|
|
instanceId: string,
|
|
page: string,
|
|
edits: readonly SavedDeferredEdit[]
|
|
): void {
|
|
try {
|
|
if (edits.length === 0) {
|
|
local.removeItem(deferredKey(instanceId, page));
|
|
return;
|
|
}
|
|
local.setItem(
|
|
deferredKey(instanceId, page),
|
|
JSON.stringify(
|
|
edits.map((edit) => ({
|
|
namespace: edit.namespace,
|
|
playlistId: edit.playlistId,
|
|
...(edit.before === undefined
|
|
? { noBefore: true }
|
|
: { before: edit.before }),
|
|
...(edit.after === undefined
|
|
? { noAfter: true }
|
|
: { after: edit.after }),
|
|
}))
|
|
)
|
|
);
|
|
} catch {
|
|
// 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;
|
|
}
|