Files
iptvnator/libs/services/src/lib/server-storage/server-unsent-writes.ts
T
4grayandClaude Opus 5.5 b1b05600d6 fix(server-storage): durable per-page journal, conflicts for edits on other tabs' changes, lock readability
- 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>
2026-10-12 09:49:21 +02:00

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;
}