fix(server-storage): keep edits as ordered steps, treat probe refusals as transient, read the cache through memory

- A queued write keeps its edits as steps: an edit continuing the last one
  joins it, one made behind a travelling write or on another tab's change
  stays apart, and a conflict replays them in order. Saved writes and
  early edits store the steps too.
- The instance probe treats any refusal but 404 (a proxy's 429 or 403) as
  unreachable, so a browser keeps its remembered server.
- Cache reads that browser storage refuses fall back to memory, so a
  re-read and later edits still work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 10:03:41 +02:00
1 parent b1b05600d6
commit 72b0d2102a
9 files changed
+213 -129

No files matched your search

@@ -74,10 +74,12 @@ describe('probeServerInstance', () => {
}
});
it('reports an unreachable server for failures and 5xx', async () => {
it('reports an unreachable server for failures, 5xx and refusals', async () => {
for (const fetchFn of [
jest.fn().mockRejectedValue(new TypeError('Failed to fetch')),
answer('Bad Gateway', 502),
answer('Too Many Requests', 429),
answer('Forbidden', 403),
// Headers arrived, then the body was cut off.
jest.fn().mockResolvedValue({
ok: true,
@@ -6,9 +6,10 @@ const PROBE_TIMEOUT_MS = 2_500;
/**
* - `skipped`: no runtime backend URL, or Electron.
* - `server` / `none`: the backend answered.
* - `unreachable`: no answer, a timeout or a 5xx (nginx answers 502 while
* the backend restarts), so the result says nothing about storage.
* - `server` / `none`: the backend answered (a 404 is an older backend).
* - `unreachable`: no answer, a timeout, a 5xx (nginx answers 502 while
* the backend restarts) or another refusal such as a proxy's 429, so the
* result says nothing about storage.
*/
export type ServerInstanceProbe =
| { readonly kind: 'skipped' | 'none' | 'unreachable' }
@@ -52,13 +53,13 @@ export async function probeServerInstance(
} catch {
return { kind: 'unreachable' };
}
if (response.status >= 500) {
return { kind: 'unreachable' };
}
if (!response.ok) {
if (response.status === 404) {
// A backend from before server storage has no /v1 routes.
return { kind: 'none' };
}
if (!response.ok) {
return { kind: 'unreachable' };
}
try {
const body: unknown = await response.json();
return isServerInstance(body) && body.storage === 'server'
+14 -10
View File
@@ -179,8 +179,8 @@ deployment wrote a runtime `BACKEND_URL` into `window.__IPTVNATOR_CONFIG__`
`storage: 'server'` it imports `apps/web/src/app/server-storage/` lazily and
merges `provideServerStorage()` into the app config, and remembers the
instance under `iptvnator-server:instance`. When the probe gets no answer
(network error, timeout, or a 5xx such as nginx's 502 while the backend
restarts), a browser that remembers this backend still starts in server
(network error, timeout, a 5xx such as nginx's 502 while the backend
restarts, or a refusal other than 404, such as a proxy's 429), a browser that remembers this backend still starts in server
mode, offline, and reloads once the server answers; it never falls back to
an empty browser library. Until that reload it sends no writes
(`holdWrites`): the address may lead to another database by then, which
@@ -220,11 +220,15 @@ settings and guards read them in every build.
as offline. Each write is sent on the tag of the entry it was made
against (`UserDataWriteQueue`); after a `412` the edit (before → after) is
replayed on the server's current value, so another device's change to the
same playlist's entry is kept. An edit queued behind a travelling write
keeps its own before → after and carries the travelling one as `prior`
(also in the saved writes): if that write was lost, a conflict replays
both in order, and if it landed, adding and then removing an item still
removes it. An edit made on another tab's unsent change, and an entry a
same playlist's entry is kept. A queued write keeps its edits as steps
(before → after, also in the saved writes): an edit continuing the last
one joins it, one made behind a travelling write or on another tab's
change is kept apart, and a conflict replays them in order. So a lost
write is not dropped, and adding and then removing an item still removes
it when the addition landed. When two tabs edit one entry before either
write lands, a removal of the other tab's unsent item holds if that tab's
write lands first, as it does when a tab is left (its writes go out on
hide); otherwise the item can come back. 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
(`UNKNOWN_TAG`), so they conflict and are replayed too. Page-hide writes report their
new tag or conflict like any other, and one behind a travelling write is
@@ -238,9 +242,9 @@ settings and guards read them in every build.
registry in `localStorage` (`iptvnator-server:unconfirmed:<instanceId>`,
one record per page load, renewed every 30 s while it has unsent writes,
stale after ten minutes) lets a re-read in one tab keep the cache values
another tab is still sending. A value too big for
`localStorage` stays readable from memory, so callers and the diff
baseline agree. A re-read
another tab is still sending. A value too big for `localStorage`, or any
value when the browser refuses storage access, stays readable from
memory, so callers and the diff baseline agree. A re-read
updates the cache; views already open show another device's changes on
their next load (no live push). Start-up waits
up to 3 s for the first read.
@@ -63,17 +63,18 @@ export function claimCache(local: UserDataStorage, instanceId: string): void {
}
}
/** Saved edits: JSON drops an undefined side, which reads back as such. */
type Steps = readonly { readonly before: unknown; readonly after: unknown }[];
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 };
/** The edits since that version (see `PendingEntry.steps`). */
readonly steps: Steps;
}
interface StoredWrite {
@@ -81,12 +82,24 @@ interface StoredWrite {
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 };
readonly steps?: unknown;
}
function readSteps(stored: unknown): Steps {
return Array.isArray(stored)
? stored
.filter((step) => step && typeof step === 'object')
.map((step: { before?: unknown; after?: unknown }) => ({
before: step.before,
after: step.after,
}))
: [];
}
const saveSteps = (steps: Steps) =>
steps.map(({ before, after }) => ({ before, after }));
/**
* Records `page` as the tab's current page load and returns the one
* before it: a reload, whose saved writes this page takes over.
@@ -160,15 +173,8 @@ export function readUnsentWrites(
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,
steps: readSteps(write.steps),
});
}
}
@@ -187,15 +193,12 @@ export function saveUnsentWrites(
return;
}
const stored: StoredWrite[] = writes.map(
({ namespace, playlistId, value, before, tag, prior }) => ({
({ namespace, playlistId, value, tag, steps }) => ({
namespace,
playlistId,
...(value === undefined ? { remove: true } : { value }),
...(before === undefined ? {} : { before }),
...(tag === undefined ? {} : { tag }),
...(prior === undefined
? {}
: { prior: { before: prior.before, after: prior.after } }),
steps: saveSteps(steps),
})
);
local.setItem(writesKey(instanceId, page), JSON.stringify(stored));
@@ -222,8 +225,7 @@ export function newestWrites<T extends UnsentWrite & { readonly edit: number }>(
export interface SavedDeferredEdit {
readonly namespace: ServerUserStateNamespace;
readonly playlistId: string;
readonly before: unknown;
readonly after: unknown;
readonly steps: Steps;
}
export function readDeferredEdits(
@@ -250,12 +252,10 @@ export function readDeferredEdits(
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'],
steps: readSteps(edit['steps']),
});
}
}
@@ -279,12 +279,7 @@ export function saveDeferredEdits(
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 }),
steps: saveSteps(edit.steps),
}))
)
);
@@ -1,12 +1,12 @@
import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces';
import { itemKey } from './server-migration-merge';
import type { EditStep } from './server-user-data-entries';
/** An edit made before the first read, applied once the server's value is known. */
/** Edits made before the first read, applied once the server's value is known. */
export interface DeferredEdit {
readonly namespace: ServerUserStateNamespace;
readonly playlistId: string;
readonly before: unknown;
readonly after: unknown;
readonly steps: readonly EditStep[];
}
/**
@@ -29,11 +29,7 @@ export function refreshedNamespace(
}
}
for (const edit of deferred) {
const merged = applyEdit(
server[edit.playlistId],
edit.before,
edit.after
);
const merged = replaySteps(server[edit.playlistId], edit.steps);
if (merged === undefined) {
delete value[edit.playlistId];
} else {
@@ -115,17 +111,14 @@ function isListOrMissing(value: unknown): value is unknown[] | undefined {
return value === undefined || Array.isArray(value);
}
/** A saved write's edits, its `prior` first, applied to `server`. */
export function replayWrite(
/** Edits replayed on `server` in order. */
export function replaySteps(
server: unknown,
write: {
readonly before?: unknown;
readonly value: unknown;
readonly prior?: { readonly before: unknown; readonly after: unknown };
}
steps: readonly { readonly before: unknown; readonly after: unknown }[]
): unknown {
const base = write.prior
? applyEdit(server, write.prior.before, write.prior.after)
: server;
return applyEdit(base, write.before, write.value);
let value = server;
for (const step of steps) {
value = applyEdit(value, step.before, step.after);
}
return value;
}
@@ -8,26 +8,47 @@ export interface PendingEntry {
readonly value: unknown;
/** The edit this value came from; a retry never replaces a newer one. */
readonly edit: number;
/** The entry as the edit found it, to replay the edit on a conflict. */
readonly before: unknown;
/**
* The server version the edit is based on: a tag, `null` for an entry
* The server version `value` is based on: a tag, `null` for an entry
* seen absent, `undefined` when unknown (an unconditional write).
*/
readonly tag?: string | null;
/**
* The edit of the write this one was queued behind, which may or may
* not have reached the server: a conflict replays it first. Kept apart
* from this edit, so adding and then removing an item is not one no-op.
* The edits since that version, oldest first: a conflict replays them
* on the server's value in order. Kept apart where they cannot be one
* before → after (another tab's change in between, or a write that may
* or may not have landed), so adding and then removing an item is
* never a no-op.
*/
readonly prior?: PriorEdit;
readonly steps: readonly EditStep[];
}
export interface PriorEdit {
/** One edit as it was made: the entry before and after it. */
export interface EditStep {
readonly before: unknown;
readonly after: unknown;
/** The write it was sent with; `0` after a reload. */
readonly edit: number;
/** The travelling write that carries it, until that one is answered. */
readonly sent?: number;
}
/**
* `steps` plus the edit `before` → `after`: joined with the last step when
* it continues it, kept apart otherwise.
*/
export function addStep(
steps: readonly EditStep[],
before: unknown,
after: unknown
): EditStep[] {
const last = steps[steps.length - 1];
if (
last &&
last.sent === undefined &&
JSON.stringify(last.after) === JSON.stringify(before)
) {
return [...steps.slice(0, -1), { before: last.before, after }];
}
return [...steps, { before, after }];
}
/**
@@ -7,12 +7,13 @@ import { ServerApiError, type ServerApiClient } from './server-api-client';
import {
entryKey,
entryPath,
addStep,
type EditStep,
type PendingEntry,
preconditionHeaders,
type PriorEdit,
UNKNOWN_TAG,
} from './server-user-data-entries';
import { replayWrite } from './server-user-data-edit';
import { replaySteps } from './server-user-data-edit';
import { newestWrites } from './server-unsent-writes';
const FLUSH_DELAY_MS = 500;
@@ -118,10 +119,9 @@ export class UserDataWriteQueue {
/**
* Queues an edit (`before` → `value`). A newer edit of an entry still
* queued joins it; one behind a travelling write keeps its own edit,
* carries the write's as `prior` and is based on the write's tag. A
* `foreign` edit was made on another tab's unsent change: its write
* must conflict and be replayed.
* queued joins it; one behind a travelling write keeps that write's
* steps (marked as sent) and tag. A `foreign` edit was made on another
* tab's unsent change: its write must conflict and be replayed.
*/
queue(
namespace: ServerUserStateNamespace,
@@ -130,7 +130,7 @@ export class UserDataWriteQueue {
before: unknown,
base: {
readonly tag?: string | null;
readonly prior?: Omit<PriorEdit, 'edit'>;
readonly steps?: readonly EditStep[];
readonly foreign?: boolean;
} = {}
): void {
@@ -140,32 +140,37 @@ export class UserDataWriteQueue {
const edit = ++this.editCount;
let entry: PendingEntry;
if (queued) {
entry = { ...queued, value, edit };
entry = {
...queued,
value,
edit,
steps: addStep(queued.steps, before, value),
};
} else if (travelling) {
const sent = travelling.steps.map((step) => ({
...step,
sent: travelling.edit,
}));
entry = {
namespace,
playlistId,
value,
edit,
before,
tag: travelling.tag,
prior: {
before: travelling.prior
? travelling.prior.before
: travelling.before,
after: travelling.value,
edit: travelling.edit,
},
steps: [...sent, { before, after: value }],
};
} else {
const tag =
base.tag !== undefined
? base.tag
: this.tagFor(namespace, playlistId);
entry = { namespace, playlistId, value, edit, before, tag };
if (base.prior) {
entry = { ...entry, prior: { ...base.prior, edit: 0 } };
}
entry = {
namespace,
playlistId,
value,
edit,
tag:
base.tag !== undefined
? base.tag
: this.tagFor(namespace, playlistId),
steps: base.steps ?? [{ before, after: value }],
};
}
if (base.foreign) {
entry = { ...entry, tag: UNKNOWN_TAG };
@@ -367,7 +372,7 @@ export class UserDataWriteQueue {
));
this.retireInFlight(entry);
if (outcome.status === 'saved') {
// Without a tag a newer edit keeps this one as `prior`.
// Without a tag a newer edit keeps this one's steps.
if (outcome.tag !== undefined) {
this.confirmed(entry, outcome.tag);
}
@@ -422,11 +427,11 @@ export class UserDataWriteQueue {
this.setTag(entry.namespace, entry.playlistId, tag);
const key = entryKey(entry);
const next = this.pending.get(key);
if (next?.prior?.edit === entry.edit) {
if (next?.steps.some((step) => step.sent === entry.edit)) {
// A foreign edit stays unknown: its baseline is not this value.
this.pending.set(key, {
...next,
prior: undefined,
steps: next.steps.filter((step) => step.sent !== entry.edit),
tag: next.tag === UNKNOWN_TAG ? UNKNOWN_TAG : tag,
});
}
@@ -434,9 +439,8 @@ export class UserDataWriteQueue {
/**
* Another device changed the entry and `entry` did not land: replay its
* edits on the server's current value (with the newer queued edit that
* carries it as `prior`, if any), show the result and send it on that
* version.
* steps on the server's current value (with the newer queued edit that
* carries them, if any), show the result and send it on that version.
*/
private replayOnConflict(
entry: PendingEntry,
@@ -450,7 +454,7 @@ export class UserDataWriteQueue {
if (!latest) {
return;
}
const merged = replayWrite(current.value, latest);
const merged = replaySteps(current.value, latest.steps);
this.hooks.show(entry.namespace, entry.playlistId, merged);
if (
latest === entry &&
@@ -461,9 +465,8 @@ export class UserDataWriteQueue {
this.pending.set(key, {
...latest,
value: merged,
before: current.value,
tag: current.tag,
prior: undefined,
steps: [{ before: current.value, after: merged }],
});
this.scheduleFlush();
}
@@ -7,6 +7,7 @@ import {
import type { UserDataStorage } from '../user-data-storage';
import type { ServerApiClient } from './server-api-client';
import {
addStep,
parseMap,
splitEditKey,
UNKNOWN_TAG,
@@ -14,7 +15,7 @@ import {
import {
type DeferredEdit,
refreshedNamespace,
replayWrite,
replaySteps,
} from './server-user-data-edit';
import { UserDataWriteQueue } from './server-user-data-queue';
import {
@@ -50,9 +51,9 @@ export class ServerUserDataStorage implements UserDataStorage {
private readonly writes: UserDataWriteQueue;
private unsentSaved = false;
/**
* Edits made before the first successful read, as before → after: the
* cache they were made against may be empty or old, so they are
* applied to the server's value once it is known (`applyEdit`).
* Edits made before the first successful read: the cache they were
* made against may be empty or old, so they are replayed on the
* server's value once it is known (`applyEdit`).
*/
private readonly deferred = new Map<string, DeferredEdit>();
private hydrated = false;
@@ -102,8 +103,8 @@ export class ServerUserDataStorage implements UserDataStorage {
write.namespace,
write.playlistId,
write.value,
write.before,
{ tag: write.tag, prior: write.prior }
undefined,
{ tag: write.tag, steps: write.steps }
);
}
for (const edit of readDeferredEdits(
@@ -131,15 +132,13 @@ export class ServerUserDataStorage implements UserDataStorage {
this.tabId
)) {
const edits = [
...readDeferredEdits(this.local, this.instanceId, page).map(
(edit) => ({ ...edit, value: edit.after })
),
...readDeferredEdits(this.local, this.instanceId, page),
...readUnsentWrites(this.local, this.instanceId, page),
];
for (const edit of edits) {
const before = this.baseline(edit.namespace);
const after = { ...before };
const value = replayWrite(before[edit.playlistId], edit);
const value = replaySteps(before[edit.playlistId], edit.steps);
if (value === undefined) {
delete after[edit.playlistId];
} else {
@@ -323,11 +322,17 @@ export class ServerUserDataStorage implements UserDataStorage {
this.adoptClosedTabs();
}
/** Memory first; browser storage that refuses access reads as empty. */
private readCache(namespace: ServerUserStateNamespace): string | null {
return (
this.overflow.get(namespace) ??
this.local.getItem(SERVER_CACHE_PREFIX + namespace)
);
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. */
@@ -398,13 +403,14 @@ export class ServerUserDataStorage implements UserDataStorage {
return;
}
const key = `${namespace}|${playlistId}`;
const earlier = this.deferred.get(key);
this.deferred.set(key, {
namespace,
playlistId,
// An earlier edit's baseline stays, even when it was empty.
before: earlier ? earlier.before : previous[playlistId],
after: value,
steps: addStep(
this.deferred.get(key)?.steps ?? [],
previous[playlistId],
value
),
});
this.saveDeferred();
this.publishUnconfirmed();
@@ -534,4 +534,63 @@ describe('ServerUserDataStorage with other devices', () => {
)
).toEqual([]);
});
it("keeps a removal of another tab's unsent item", async () => {
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
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();
tabA.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
tabB.setItem(
'xtream-favorites',
JSON.stringify({ p1: [...favorites(tabB).p1, 'Y'] })
);
// Tab A removes the Y it sees from tab B before sending anything;
// tab B's write lands first (leaving a tab sends its writes).
tabA.setItem(
'xtream-favorites',
JSON.stringify({
p1: favorites(tabA).p1.filter((id: string) => id !== 'Y'),
})
);
await tabB.flush();
await tabB.flush();
await tabA.flush();
await tabA.flush();
expect(tabA.hasUnsentWrites() || tabB.hasUnsentWrites()).toBe(false);
expect(
[...(server.state['xtream-favorites']['p1'] as string[])].sort()
).toEqual(['S', 'X']);
});
it('reads and writes through memory when browser storage refuses', async () => {
const server = fakeServer({ 'xtream-favorites': { p1: ['S'] } });
const denied = () => {
throw new DOMException('denied', 'SecurityError');
};
const phone = new ServerUserDataStorage(
server.api as never,
{ getItem: denied, setItem: denied, removeItem: denied },
jest.fn()
);
await phone.hydrate();
expect(favorites(phone)).toEqual({ p1: ['S'] });
phone.setItem('xtream-favorites', JSON.stringify({ p1: ['S', 'X'] }));
await phone.flush();
expect(server.state['xtream-favorites']['p1']).toEqual(['S', 'X']);
});
});