mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
1 parent
50fd1c4a63
commit
2c805e0b61
6 files changed
+215
-8
No files matched your search
@@ -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(() => {
|
||||
|
||||
Reference in new issue
Block a user