fix(server-storage): apply early edits to the server value, newest unsent write, sticky sign-out

- Edits made before the first successful user-data read are kept as
  before → after and applied to the server's value once it arrives
  (applyEdit), so a new device or a stale cache adds a favorite instead of
  replacing the server's list.
- Saved unsent writes keep only the newest per entry; an answered older
  write no longer retires a newer one still travelling.
- A late failure after sign-out no longer turns unauthorized into offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 08:05:24 +02:00
1 parent 21f640ccc1
commit 50fd1c4a63
9 files changed
+353 -44

No files matched your search

+4 -2
View File
@@ -209,8 +209,10 @@ settings and guards read them in every build.
re-reads run one at a time and keep entries edited while they were in
flight or not yet saved. A re-read whose result does not fit the cache
keeps the old cache and baseline together. A response body cut off after
its headers counts as offline. Before the first read finishes, an edit is
compared with the cache, so it sends only its own entry. A re-read
its headers counts as offline. Edits made before the first successful read
are held as before → after and applied to the server's value once it
arrives (`applyEdit`): a new device or a stale cache adds its favorite
instead of replacing the server's list. 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.
@@ -43,8 +43,9 @@ describe('ServerStorageStatusService', () => {
status.reportUnauthorized();
expect(status.state()).toBe('unauthorized');
// A late answer to a request sent with the old token.
// Late answers to requests sent with the old token.
status.reportSuccess();
status.reportOffline();
expect(status.state()).toBe('unauthorized');
});
});
@@ -41,7 +41,8 @@ export class ServerStorageStatusService {
}
reportOffline(): void {
if (this.isActive()) {
// A late failure after sign-out must not unlock the workspace.
if (this.isActive() && this.stateSignal() !== 'unauthorized') {
this.stateSignal.set('offline');
}
}
@@ -45,7 +45,7 @@ export function unionItems<T>(...lists: readonly (readonly T[])[]): T[] {
* Content ids or URLs identify favorites and history entries; a playback
* position is identified by its content type and Xtream id.
*/
function itemKey(item: unknown): string {
export function itemKey(item: unknown): string {
if (!isPlainObject(item)) {
return `value:${JSON.stringify(item)}`;
}
@@ -104,3 +104,18 @@ export function saveUnsentWrites(
// 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()];
}
@@ -0,0 +1,61 @@
import { applyEdit } from './server-user-data-edit';
describe('applyEdit', () => {
it('adds the new favorite to what the server already has', () => {
expect(applyEdit(['live:1', 'live:2'], [], ['live:9'])).toEqual([
'live:1',
'live:2',
'live:9',
]);
expect(applyEdit(['live:1'], undefined, ['live:9'])).toEqual([
'live:1',
'live:9',
]);
});
it('removes only what the edit removed and replaces changed items', () => {
expect(
applyEdit(
[
{
contentType: 'vod',
contentXtreamId: 1,
positionSeconds: 5,
},
'other',
],
[
{
contentType: 'vod',
contentXtreamId: 1,
positionSeconds: 5,
},
],
[
{
contentType: 'vod',
contentXtreamId: 1,
positionSeconds: 60,
},
]
)
).toEqual([
{ contentType: 'vod', contentXtreamId: 1, positionSeconds: 60 },
'other',
]);
expect(applyEdit(['a', 'b'], ['a'], [])).toEqual(['b']);
// Another device removed 'a'; this edit only added 'c'.
expect(applyEdit(['b'], ['a', 'b'], ['a', 'b', 'c'])).toEqual([
'b',
'c',
]);
});
it('merges maps by key and passes deletes through', () => {
expect(applyEdit({ a: 1, b: 2 }, { a: 1 }, { c: 3 })).toEqual({
b: 2,
c: 3,
});
expect(applyEdit(['x'], ['x'], undefined)).toBeUndefined();
});
});
@@ -0,0 +1,116 @@
import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces';
import { itemKey } from './server-migration-merge';
/** An edit 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;
}
/**
* One namespace's cache after a read: the server's map, except entries the
* server does not have yet (`keepCached`, from the cache) and edits made
* before the first read (applied to the server's value).
*/
export function refreshedNamespace(
server: Readonly<Record<string, unknown>>,
cached: Readonly<Record<string, unknown>>,
keepCached: ReadonlySet<string>,
deferred: readonly DeferredEdit[]
): Record<string, unknown> {
const value: Record<string, unknown> = { ...server };
for (const playlistId of keepCached) {
if (playlistId in cached) {
value[playlistId] = cached[playlistId];
} else {
delete value[playlistId];
}
}
for (const edit of deferred) {
const merged = applyEdit(
server[edit.playlistId],
edit.before,
edit.after
);
if (merged === undefined) {
delete value[edit.playlistId];
} else {
value[edit.playlistId] = merged;
}
}
return value;
}
/**
* Applies one local edit (`before` → `after`, as the user made it against
* an older or empty cache) to the server's current value: list items the
* edit added or changed are set, items it removed are dropped, everything
* else the server has stays. Maps work the same by key.
*/
export function applyEdit(
server: unknown,
before: unknown,
after: unknown
): unknown {
if (after === undefined) {
return undefined;
}
if (
Array.isArray(after) &&
isListOrMissing(server) &&
isListOrMissing(before)
) {
const was = new Map(
(before ?? []).map((item) => [itemKey(item), item])
);
const now = new Map(after.map((item) => [itemKey(item), item]));
const result = (server ?? []).filter((item) => {
const key = itemKey(item);
return !(was.has(key) && !now.has(key));
});
for (const [key, item] of now) {
if (JSON.stringify(was.get(key)) === JSON.stringify(item)) {
// Untouched by the edit: the server's state of it wins.
continue;
}
const index = result.findIndex((entry) => itemKey(entry) === key);
if (index < 0) {
result.push(item);
} else {
result[index] = item;
}
}
return result;
}
if (isMap(after) && isMapOrMissing(server) && isMapOrMissing(before)) {
const result: Record<string, unknown> = { ...(server ?? {}) };
for (const key of Object.keys(before ?? {})) {
if (!(key in after)) {
delete result[key];
}
}
for (const [key, value] of Object.entries(after)) {
if (JSON.stringify((before ?? {})[key]) !== JSON.stringify(value)) {
result[key] = value;
}
}
return result;
}
return after;
}
function isMap(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function isMapOrMissing(
value: unknown
): value is Record<string, unknown> | undefined {
return value === undefined || isMap(value);
}
function isListOrMissing(value: unknown): value is unknown[] | undefined {
return value === undefined || Array.isArray(value);
}
@@ -325,7 +325,7 @@ describe('ServerUserDataStorage', () => {
);
});
it('sends only the edited entry before the first read finished', () => {
it('applies an edit made before the first read to the server value', async () => {
const { api, local, storage } = setup();
// The cache from the last visit; no read from the server yet.
local.setItem(
@@ -337,17 +337,51 @@ describe('ServerUserDataStorage', () => {
'xtream-favorites',
JSON.stringify({ p1: ['a', 'c'], p2: ['b'] })
);
void storage.flush();
await storage.flush();
expect(api.sendJson).not.toHaveBeenCalled();
return settle().then(() => {
expect(api.sendJson.mock.calls).toEqual([
[
'PUT',
'/user-state/xtream-favorites/p1',
{ value: ['a', 'c'] },
],
]);
// Meanwhile another device added 'live:1' and 'z'.
api.getJson.mockResolvedValueOnce({
entries: {
'xtream-favorites': { p1: ['live:1', 'a'], p2: ['b', 'z'] },
},
});
await storage.hydrate();
await storage.flush();
expect(api.sendJson.mock.calls).toEqual([
[
'PUT',
'/user-state/xtream-favorites/p1',
{ value: ['live:1', 'a', 'c'] },
],
]);
expect(JSON.parse(storage.getItem('xtream-favorites') ?? '')).toEqual({
p1: ['live:1', 'a', 'c'],
p2: ['b', 'z'],
});
});
it('keeps the server favorites when a new device adds one early', async () => {
const { api, storage } = setup();
let answer: (value: unknown) => void = () => undefined;
api.getJson.mockReturnValueOnce(
new Promise((resolve) => (answer = resolve))
);
const firstRead = storage.hydrate();
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['live:9'] }));
answer({
entries: { 'xtream-favorites': { p1: ['live:1', 'live:2'] } },
});
await firstRead;
await storage.flush();
expect(api.sendJson).toHaveBeenCalledWith(
'PUT',
'/user-state/xtream-favorites/p1',
{ value: ['live:1', 'live:2', 'live:9'] }
);
});
it('never retries an older page-hide write over a newer one', async () => {
@@ -506,4 +540,33 @@ describe('ServerUserDataStorage', () => {
);
expect(local.values.get('iptvnator-server:cache-owner')).toBe('new-db');
});
it('keeps a newer travelling write when an older one is answered', async () => {
const { api, local, storage } = setup();
await storage.hydrate();
let answerOlder: () => void = () => undefined;
api.sendJson.mockReturnValueOnce(
new Promise<void>((resolve) => (answerOlder = resolve))
);
api.sendKeepalive.mockReturnValueOnce(new Promise(() => undefined));
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['old'] }));
const older = storage.flush();
await settle();
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['new'] }));
storage.flushWithKeepalive();
answerOlder();
await older;
await settle();
expect(storage.hasUnsentWrites()).toBe(true);
expect(
JSON.parse(
local.values.get('iptvnator-server:unsent-writes:default') ??
'[]'
)
).toEqual([
{ namespace: 'xtream-favorites', playlistId: 'p1', value: ['new'] },
]);
});
});
@@ -13,8 +13,10 @@ import {
type PendingEntry,
splitEditKey,
} from './server-user-data-entries';
import { type DeferredEdit, refreshedNamespace } from './server-user-data-edit';
import {
claimCache,
newestWrites,
readUnsentWrites,
saveUnsentWrites,
} from './server-unsent-writes';
@@ -47,6 +49,13 @@ export class ServerUserDataStorage implements UserDataStorage {
/** Writes still travelling (normal or page-hide), by entry key. */
private readonly inFlight = new Map<string, PendingEntry>();
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`).
*/
private readonly deferred = new Map<string, DeferredEdit>();
private hydrated = false;
/** Re-reads run one at a time, so an older one never lands last. */
private hydration: Promise<void> = Promise.resolve();
/** Edit counter per `namespace|playlistId`, for hydrate to keep them. */
@@ -71,7 +80,11 @@ export class ServerUserDataStorage implements UserDataStorage {
/** Whether a write is queued, being retried, or still travelling. */
hasUnsentWrites(): boolean {
return this.pending.size > 0 || this.inFlight.size > 0;
return (
this.pending.size > 0 ||
this.inFlight.size > 0 ||
this.deferred.size > 0
);
}
getItem(key: string): string | null {
@@ -120,33 +133,32 @@ export class ServerUserDataStorage implements UserDataStorage {
await this.flush();
const { entries } =
await this.api.getJson<ServerUserStateResponse>('/user-state');
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
const value: Record<string, unknown> = {
...(entries[namespace] ?? {}),
};
const current = parseMap(
this.local.getItem(SERVER_CACHE_PREFIX + namespace) ?? '{}'
);
const unsaved = new Set([
...this.pending.keys(),
...this.inFlight.keys(),
]);
for (const [key, edit] of this.edits) {
if (edit > editsBefore) {
unsaved.add(key);
}
const unsaved = new Set([
...this.pending.keys(),
...this.inFlight.keys(),
]);
for (const [key, edit] of this.edits) {
if (edit > editsBefore) {
unsaved.add(key);
}
}
const deferred = [...this.deferred.values()];
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
const keepCached = new Set<string>();
for (const key of unsaved) {
const [editedNamespace, playlistId] = splitEditKey(key);
if (editedNamespace !== namespace) {
continue;
}
if (playlistId in current) {
value[playlistId] = current[playlistId];
} else {
delete value[playlistId];
if (editedNamespace === namespace) {
keepCached.add(playlistId);
}
}
const value = refreshedNamespace(
entries[namespace] ?? {},
parseMap(
this.local.getItem(SERVER_CACHE_PREFIX + namespace) ?? '{}'
),
keepCached,
deferred.filter((edit) => edit.namespace === namespace)
);
try {
this.local.setItem(
SERVER_CACHE_PREFIX + namespace,
@@ -168,6 +180,19 @@ export class ServerUserDataStorage implements UserDataStorage {
this.edits.delete(key);
}
}
if (!this.hydrated) {
// From here edits go straight to the queue.
this.hydrated = true;
this.deferred.clear();
for (const edit of deferred) {
this.queue(
edit.namespace,
edit.playlistId,
this.synced.get(edit.namespace)?.[edit.playlistId]
);
}
this.scheduleFlush();
}
}
/** Sends the queued entries after any batch still travelling. */
@@ -253,16 +278,29 @@ export class ServerUserDataStorage implements UserDataStorage {
next: Record<string, unknown>,
previous: Record<string, unknown>
): void {
const change = (playlistId: string, value: unknown) => {
if (this.hydrated) {
this.queue(namespace, playlistId, value);
return;
}
const key = `${namespace}|${playlistId}`;
this.deferred.set(key, {
namespace,
playlistId,
before: this.deferred.get(key)?.before ?? previous[playlistId],
after: value,
});
};
for (const [playlistId, value] of Object.entries(next)) {
if (
JSON.stringify(previous[playlistId]) !== JSON.stringify(value)
) {
this.queue(namespace, playlistId, value);
change(playlistId, value);
}
}
for (const playlistId of Object.keys(previous)) {
if (!(playlistId in next)) {
this.queue(namespace, playlistId, undefined);
change(playlistId, undefined);
}
}
this.synced.set(namespace, next);
@@ -320,7 +358,7 @@ export class ServerUserDataStorage implements UserDataStorage {
const ok = await (entry.value === undefined
? this.api.sendKeepalive('DELETE', path)
: this.api.sendKeepalive('PUT', path, { value: entry.value }));
this.inFlight.delete(entryKey(entry));
this.retireInFlight(entry);
if (!ok) {
this.requeue(entry);
}
@@ -336,19 +374,31 @@ export class ServerUserDataStorage implements UserDataStorage {
value: entry.value,
});
}
this.inFlight.delete(entryKey(entry));
this.retireInFlight(entry);
this.saveUnsent();
} catch (error) {
this.inFlight.delete(entryKey(entry));
this.retireInFlight(entry);
this.requeue(entry);
this.saveUnsent();
this.onWriteFailure(error);
}
}
/** A newer write of the same entry may have replaced it meanwhile. */
private retireInFlight(entry: PendingEntry): void {
const key = entryKey(entry);
if (this.inFlight.get(key)?.edit === entry.edit) {
this.inFlight.delete(key);
}
}
/** Persists what the server has not confirmed; skips idle rewrites. */
private saveUnsent(): void {
const writes = [...this.pending.values(), ...this.inFlight.values()];
// A newer edit can be queued while an older one is still travelling.
const writes = newestWrites([
...this.inFlight.values(),
...this.pending.values(),
]);
if (writes.length === 0 && !this.unsentSaved) {
return;
}