fix(server-storage): keep early edits across reloads and tabs, numeric-string legacy ids

- Edits made before the first read are saved (before → after) and
  restored after a reload; a second early edit keeps the first one's
  baseline, even an empty one; a cache-full first read still applies them
  to the server's value.
- Queued writes are saved as soon as they are queued, and a re-read keeps
  entries another tab has not confirmed yet.
- Legacy Xtream ids stored as numeric strings are typed too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 08:17:29 +02:00
1 parent 50fd1c4a63
commit 2c805e0b61
6 files changed
+215 -8

No files matched your search

+3 -1
View File
@@ -212,7 +212,9 @@ settings and guards read them in every build.
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
instead of replacing the server's list. They are saved too
(`iptvnator-server:deferred-edits:<instanceId>`), and queued writes are
saved as soon as they are queued, so a re-read in another tab keeps them. 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.
@@ -52,4 +52,15 @@ describe('typeLegacyXtreamRefs', () => {
'live:42': { xtream_id: 42, type: 'live' },
});
});
it('types numeric-string ids too', () => {
expect(
typeLegacyXtreamRefs({
'xtream-favorites': ['42'],
'xtream-collection-items': {
'42': { xtream_id: 42, type: 'movie' },
},
})['xtream-favorites']
).toEqual(['movie:42']);
});
});
@@ -15,14 +15,21 @@ const SNAPSHOTS = 'xtream-collection-items';
export function typeLegacyXtreamRefs(data: XtreamUserData): XtreamUserData {
const snapshots = isRecord(data[SNAPSHOTS]) ? data[SNAPSHOTS] : {};
const typed = (key: unknown): unknown => {
if (typeof key !== 'number') {
// Bare ids were stored as numbers and, older still, numeric strings.
const id =
typeof key === 'number'
? key
: typeof key === 'string' && /^\d+$/.test(key)
? Number(key)
: null;
if (id === null) {
return key;
}
const snapshot = snapshots[String(key)];
const snapshot = snapshots[String(id)];
return isRecord(snapshot) &&
snapshot['xtream_id'] === key &&
snapshot['xtream_id'] === id &&
typeof snapshot['type'] === 'string'
? `${snapshot['type']}:${key}`
? `${snapshot['type']}:${id}`
: key;
};
@@ -16,6 +16,10 @@ export const UNSENT_WRITES_KEY = 'iptvnator-server:unsent-writes';
export const CACHE_OWNER_KEY = 'iptvnator-server:cache-owner';
const writesKey = (instanceId: string) => `${UNSENT_WRITES_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) =>
`${DEFERRED_EDITS_KEY}:${instanceId}`;
/**
* Another database behind this address (a new volume, another backend):
@@ -33,6 +37,7 @@ export function claimCache(local: UserDataStorage, instanceId: string): void {
}
if (owner) {
local.removeItem(writesKey(owner));
local.removeItem(deferredKey(owner));
}
local.setItem(CACHE_OWNER_KEY, instanceId);
} catch {
@@ -119,3 +124,73 @@ export function newestWrites<T extends UnsentWrite & { readonly edit: number }>(
}
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
): SavedDeferredEdit[] {
let stored: unknown;
try {
stored = JSON.parse(local.getItem(deferredKey(instanceId)) ?? '[]');
} 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,
edits: readonly SavedDeferredEdit[]
): void {
try {
if (edits.length === 0) {
local.removeItem(deferredKey(instanceId));
return;
}
local.setItem(
deferredKey(instanceId),
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.
}
}
@@ -569,4 +569,94 @@ describe('ServerUserDataStorage', () => {
{ namespace: 'xtream-favorites', playlistId: 'p1', value: ['new'] },
]);
});
it('keeps two early additions to an empty entry', 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: ['A'] }));
storage.setItem('xtream-favorites', JSON.stringify({ p1: ['A', 'B'] }));
answer({ entries: { 'xtream-favorites': { p1: ['S'] } } });
await firstRead;
await storage.flush();
expect(api.sendJson).toHaveBeenCalledWith(
'PUT',
'/user-state/xtream-favorites/p1',
{ value: ['S', 'A', 'B'] }
);
});
it('keeps early edits across a reload before the first read', async () => {
const first = setup();
first.api.getJson.mockReturnValueOnce(new Promise(() => undefined));
void first.storage.hydrate();
first.storage.setItem(
'xtream-favorites',
JSON.stringify({ p1: ['mine'] })
);
const api = {
...first.api,
sendJson: jest.fn().mockResolvedValue(undefined),
getJson: jest.fn().mockResolvedValue({
entries: { 'xtream-favorites': { p1: ['theirs'] } },
}),
};
const reloaded = new ServerUserDataStorage(
api as never,
first.local,
jest.fn()
);
await reloaded.hydrate();
await reloaded.flush();
expect(api.sendJson).toHaveBeenCalledWith(
'PUT',
'/user-state/xtream-favorites/p1',
{ value: ['theirs', 'mine'] }
);
});
it("does not replace another tab's unconfirmed edit on a re-read", async () => {
// A server that keeps what it is sent.
const state: Record<string, Record<string, unknown>> = {
'xtream-favorites': { p1: ['live:1'] },
};
const tabA = setup();
tabA.api.getJson.mockImplementation(async () => ({
entries: structuredClone(state),
}));
tabA.api.sendJson.mockImplementation(
async (_method: string, path: string, body: { value: unknown }) => {
const [, , namespace, playlistId] = path.split('/');
state[namespace] = {
...state[namespace],
[playlistId]: body.value,
};
}
);
await tabA.storage.hydrate();
tabA.storage.setItem(
'xtream-favorites',
JSON.stringify({ p1: ['live:1', 'live:7'] })
);
// Tab A's write is queued, not sent yet; tab B starts and re-reads.
const tabB = new ServerUserDataStorage(
tabA.api as never,
tabA.local,
jest.fn()
);
await tabB.hydrate();
expect(JSON.parse(tabB.getItem('xtream-favorites') ?? '')).toEqual({
p1: ['live:1', 'live:7'],
});
expect(state['xtream-favorites']['p1']).toEqual(['live:1', 'live:7']);
});
});
@@ -17,7 +17,9 @@ import { type DeferredEdit, refreshedNamespace } from './server-user-data-edit';
import {
claimCache,
newestWrites,
readDeferredEdits,
readUnsentWrites,
saveDeferredEdits,
saveUnsentWrites,
} from './server-unsent-writes';
@@ -76,6 +78,9 @@ export class ServerUserDataStorage implements UserDataStorage {
this.queue(write.namespace, write.playlistId, write.value);
}
this.unsentSaved = this.pending.size > 0;
for (const edit of readDeferredEdits(local, instanceId)) {
this.deferred.set(`${edit.namespace}|${edit.playlistId}`, edit);
}
}
/** Whether a write is queued, being retried, or still travelling. */
@@ -142,7 +147,12 @@ export class ServerUserDataStorage implements UserDataStorage {
unsaved.add(key);
}
}
// Another tab's unconfirmed writes are in the shared cache too.
for (const write of readUnsentWrites(this.local, this.instanceId)) {
unsaved.add(`${write.namespace}|${write.playlistId}`);
}
const deferred = [...this.deferred.values()];
const refreshed = new Map<string, Record<string, unknown>>();
for (const namespace of SERVER_USER_STATE_NAMESPACES) {
const keepCached = new Set<string>();
for (const key of unsaved) {
@@ -159,6 +169,7 @@ export class ServerUserDataStorage implements UserDataStorage {
keepCached,
deferred.filter((edit) => edit.namespace === namespace)
);
refreshed.set(namespace, value);
try {
this.local.setItem(
SERVER_CACHE_PREFIX + namespace,
@@ -188,9 +199,10 @@ export class ServerUserDataStorage implements UserDataStorage {
this.queue(
edit.namespace,
edit.playlistId,
this.synced.get(edit.namespace)?.[edit.playlistId]
refreshed.get(edit.namespace)?.[edit.playlistId]
);
}
saveDeferredEdits(this.local, this.instanceId, []);
this.scheduleFlush();
}
}
@@ -284,12 +296,17 @@ export class ServerUserDataStorage implements UserDataStorage {
return;
}
const key = `${namespace}|${playlistId}`;
const earlier = this.deferred.get(key);
this.deferred.set(key, {
namespace,
playlistId,
before: this.deferred.get(key)?.before ?? previous[playlistId],
// An earlier edit's baseline stays, even when it was empty.
before: earlier ? earlier.before : previous[playlistId],
after: value,
});
saveDeferredEdits(this.local, this.instanceId, [
...this.deferred.values(),
]);
};
for (const [playlistId, value] of Object.entries(next)) {
if (
@@ -323,7 +340,12 @@ export class ServerUserDataStorage implements UserDataStorage {
}
private scheduleFlush(): void {
if (this.flushTimer !== null || this.pending.size === 0) {
if (this.pending.size === 0) {
return;
}
// Saved at once: another tab's re-read must not replace them.
this.saveUnsent();
if (this.flushTimer !== null) {
return;
}
this.flushTimer = setTimeout(() => {