mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
fix(server-storage): unsent writes survive reloads, guarded move, lazy storage
- User-data writes the server has not confirmed are kept under iptvnator-server:unsent-writes and sent first on the next visit. - Moving browser data does not start while user-data writes are unsent, and a duplicate of a failed upload counts as failed, not merged. - The session reaches localStorage through the lazy adapter, so a browser that denies storage still boots. - Raise launch/renderer.domMutationsToFirstCard by 2 (569 → 571): the rail server shortcut and the offline banner each leave an @if anchor in every build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
72c84a3eba
commit
abae207d02
9 files changed
+264
-49
No files matched your search
@@ -200,7 +200,8 @@ settings and guards read them in every build.
|
||||
each change sends only the playlist entries that differ, debounced, with a
|
||||
`keepalive` flush when the page hides. Batches go out one after another
|
||||
(the page-hide flush waits for a batch still travelling), a failed write,
|
||||
including a refused `keepalive` one, stays queued for the next flush, and
|
||||
including a refused `keepalive` one, stays queued for the next flush and
|
||||
survives a reload under `iptvnator-server:unsent-writes`, and
|
||||
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
|
||||
@@ -237,7 +238,8 @@ settings and guards read them in every build.
|
||||
translated through the URL; nothing is dropped), and the legacy Xtream maps follow under
|
||||
the server id; `combineUserData` unions lists (playback positions by
|
||||
content type and Xtream id, renamed to the server playlist) and merges
|
||||
maps, the server's value winning on a conflict. Browser data is never deleted, and
|
||||
maps, the server's value winning on a conflict. Browser data is never
|
||||
deleted, the move does not start while user-data writes are unsent, and
|
||||
the marker is written only when nothing failed, so Settings → Server
|
||||
keeps offering a partial move.
|
||||
|
||||
|
||||
@@ -23,7 +23,11 @@ describe('ServerMigrationService', () => {
|
||||
dbUpsertAppPlaylist: jest.Mock;
|
||||
};
|
||||
api: { getJson: jest.Mock; sendJson: jest.Mock };
|
||||
userData: { flush: jest.Mock; hydrate: jest.Mock };
|
||||
userData: {
|
||||
flush: jest.Mock;
|
||||
hasUnsentWrites: jest.Mock;
|
||||
hydrate: jest.Mock;
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -42,6 +46,7 @@ describe('ServerMigrationService', () => {
|
||||
},
|
||||
userData: {
|
||||
flush: jest.fn().mockResolvedValue(undefined),
|
||||
hasUnsentWrites: jest.fn(() => false),
|
||||
hydrate: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
@@ -265,4 +270,42 @@ describe('ServerMigrationService', () => {
|
||||
});
|
||||
expect(migration.browserDataMoved()).toBe(true);
|
||||
});
|
||||
|
||||
it('does not start while user-data writes are still unsent', async () => {
|
||||
session.userData.hasUnsentWrites.mockReturnValue(true);
|
||||
const migration = TestBed.inject(ServerMigrationService);
|
||||
|
||||
await expect(migration.moveBrowserData()).rejects.toThrow('Unsent');
|
||||
expect(
|
||||
session.documentStore.dbUpsertAppPlaylist
|
||||
).not.toHaveBeenCalled();
|
||||
expect(session.api.sendJson).not.toHaveBeenCalled();
|
||||
expect(migration.browserDataMoved()).toBe(false);
|
||||
});
|
||||
|
||||
it('counts a duplicate of a failed upload as failed, not merged', async () => {
|
||||
TestBed.overrideProvider(NgxIndexedDBService, {
|
||||
useValue: {
|
||||
getAll: () =>
|
||||
of([
|
||||
browserPlaylists[1],
|
||||
{ ...browserPlaylists[1], _id: 'x2' },
|
||||
]),
|
||||
},
|
||||
});
|
||||
session.documentStore.dbUpsertAppPlaylist.mockRejectedValue(
|
||||
new Error('500')
|
||||
);
|
||||
localStorage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ x: ['live:1'], x2: ['live:2'] })
|
||||
);
|
||||
|
||||
const result = await TestBed.inject(
|
||||
ServerMigrationService
|
||||
).moveBrowserData();
|
||||
|
||||
expect(result).toEqual({ imported: 0, merged: 0, failed: 2 });
|
||||
expect(session.api.sendJson).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,11 @@ export class ServerMigrationService {
|
||||
}
|
||||
|
||||
async moveBrowserData(): Promise<ServerMigrationResult> {
|
||||
// An edit still unsent could land after the combined values below.
|
||||
await this.session.userData.flush();
|
||||
if (this.session.userData.hasUnsentWrites()) {
|
||||
throw new Error('Unsent user data; try again when online');
|
||||
}
|
||||
const [browser, server] = await Promise.all([
|
||||
this.readBrowserPlaylists(),
|
||||
this.session.documentStore.dbGetAppPlaylistMetas(),
|
||||
@@ -95,9 +100,14 @@ export class ServerMigrationService {
|
||||
let merged = 0;
|
||||
let failed = 0;
|
||||
|
||||
const onServer = new Set(server.map((playlist) => playlist._id));
|
||||
for (const step of planServerMigration(browser, server)) {
|
||||
try {
|
||||
if (step.kind === 'merge') {
|
||||
// A duplicate of a browser playlist whose upload failed.
|
||||
if (!onServer.has(step.serverId)) {
|
||||
throw new Error('Merge target was not uploaded');
|
||||
}
|
||||
const playlist = byId.get(step.browserId);
|
||||
if (playlist) {
|
||||
await this.mergeDocumentUserData(
|
||||
@@ -112,6 +122,7 @@ export class ServerMigrationService {
|
||||
step.playlist
|
||||
);
|
||||
imported += 1;
|
||||
onServer.add(step.playlist._id);
|
||||
targets.set(step.playlist._id, step.playlist._id);
|
||||
}
|
||||
} catch {
|
||||
@@ -119,8 +130,6 @@ export class ServerMigrationService {
|
||||
}
|
||||
}
|
||||
|
||||
// Queued edits go out first, so none lands after the combined values.
|
||||
await this.session.userData.flush();
|
||||
failed += await this.copyXtreamUserData(targets);
|
||||
await this.session.userData.hydrate();
|
||||
if (failed === 0) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SERVER_SIGN_IN_PATH,
|
||||
ServerStorageStatusService,
|
||||
} from '../server-storage-status.service';
|
||||
import { createBrowserUserDataStorage } from '../user-data-storage';
|
||||
import { ServerApiClient, type ServerApiError } from './server-api-client';
|
||||
import { ServerPlaylistDocumentStore } from './server-playlist-document-store';
|
||||
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
|
||||
@@ -39,7 +40,8 @@ export class ServerStorageSession {
|
||||
readonly documentStore = new ServerPlaylistDocumentStore(this.api);
|
||||
readonly userData = new ServerUserDataStorage(
|
||||
this.api,
|
||||
globalThis.localStorage,
|
||||
// Lazy: a browser that denies storage must not stop the app here.
|
||||
createBrowserUserDataStorage(),
|
||||
() => this.announceWriteFailure()
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
isServerUserStateNamespace,
|
||||
type ServerUserStateNamespace,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { UserDataStorage } from '../user-data-storage';
|
||||
|
||||
/**
|
||||
* User-data writes the server has not confirmed, kept across reloads: a
|
||||
* failed write, or a page-hide write whose answer the page did not live to
|
||||
* see, is sent again on the next visit instead of being replaced by the
|
||||
* server's older value.
|
||||
*/
|
||||
export const UNSENT_WRITES_KEY = 'iptvnator-server:unsent-writes';
|
||||
|
||||
export interface UnsentWrite {
|
||||
readonly namespace: ServerUserStateNamespace;
|
||||
readonly playlistId: string;
|
||||
/** `undefined` deletes the entry. */
|
||||
readonly value: unknown;
|
||||
}
|
||||
|
||||
interface StoredWrite {
|
||||
readonly namespace: string;
|
||||
readonly playlistId: string;
|
||||
readonly value?: unknown;
|
||||
readonly remove?: true;
|
||||
}
|
||||
|
||||
export function readUnsentWrites(local: UserDataStorage): UnsentWrite[] {
|
||||
let stored: unknown;
|
||||
try {
|
||||
stored = JSON.parse(local.getItem(UNSENT_WRITES_KEY) ?? '[]');
|
||||
} 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
return writes;
|
||||
}
|
||||
|
||||
export function saveUnsentWrites(
|
||||
local: UserDataStorage,
|
||||
writes: readonly UnsentWrite[]
|
||||
): void {
|
||||
try {
|
||||
if (writes.length === 0) {
|
||||
local.removeItem(UNSENT_WRITES_KEY);
|
||||
return;
|
||||
}
|
||||
const stored: StoredWrite[] = writes.map(
|
||||
({ namespace, playlistId, value }) =>
|
||||
value === undefined
|
||||
? { namespace, playlistId, remove: true }
|
||||
: { namespace, playlistId, value }
|
||||
);
|
||||
local.setItem(UNSENT_WRITES_KEY, JSON.stringify(stored));
|
||||
} catch {
|
||||
// Storage unavailable or full: the queue stays in memory only.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ServerUserStateNamespace } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** One playlist entry of a synced user-data map, on its way to the server. */
|
||||
export interface PendingEntry {
|
||||
readonly namespace: ServerUserStateNamespace;
|
||||
readonly playlistId: string;
|
||||
/** `undefined` deletes the entry. */
|
||||
readonly value: unknown;
|
||||
/** The edit this value came from; a retry never replaces a newer one. */
|
||||
readonly edit: number;
|
||||
}
|
||||
|
||||
export function entryKey(entry: PendingEntry): string {
|
||||
return `${entry.namespace}|${entry.playlistId}`;
|
||||
}
|
||||
|
||||
export function splitEditKey(key: string): [string, string] {
|
||||
const separator = key.indexOf('|');
|
||||
return [key.slice(0, separator), key.slice(separator + 1)];
|
||||
}
|
||||
|
||||
export function entryPath(entry: PendingEntry): string {
|
||||
return `/user-state/${entry.namespace}/${encodeURIComponent(entry.playlistId)}`;
|
||||
}
|
||||
|
||||
export function parseMap(value: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -406,4 +406,50 @@ describe('ServerUserDataStorage', () => {
|
||||
{ value: ['live:1', 'live:3'] }
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps unconfirmed writes across a reload and sends them first', async () => {
|
||||
const first = setup();
|
||||
await first.storage.hydrate();
|
||||
first.api.sendJson.mockRejectedValue(new Error('offline'));
|
||||
first.storage.setItem(
|
||||
'xtream-favorites',
|
||||
JSON.stringify({ p1: ['live:1', 'live:9'] })
|
||||
);
|
||||
await first.storage.flush();
|
||||
expect(first.storage.hasUnsentWrites()).toBe(true);
|
||||
expect(first.local.values.get('iptvnator-server:unsent-writes')).toBe(
|
||||
JSON.stringify([
|
||||
{
|
||||
namespace: 'xtream-favorites',
|
||||
playlistId: 'p1',
|
||||
value: ['live:1', 'live:9'],
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
// Reload: a new instance over the same browser storage.
|
||||
const api = {
|
||||
...first.api,
|
||||
sendJson: jest.fn().mockResolvedValue(undefined),
|
||||
getJson: jest.fn().mockResolvedValue({
|
||||
entries: { 'xtream-favorites': { p1: ['live:1', 'live:9'] } },
|
||||
}),
|
||||
};
|
||||
const reloaded = new ServerUserDataStorage(
|
||||
api as never,
|
||||
first.local,
|
||||
jest.fn()
|
||||
);
|
||||
await reloaded.hydrate();
|
||||
|
||||
expect(api.sendJson).toHaveBeenCalledWith(
|
||||
'PUT',
|
||||
'/user-state/xtream-favorites/p1',
|
||||
{ value: ['live:1', 'live:9'] }
|
||||
);
|
||||
expect(reloaded.hasUnsentWrites()).toBe(false);
|
||||
expect(first.local.values.has('iptvnator-server:unsent-writes')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,20 +6,19 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { UserDataStorage } from '../user-data-storage';
|
||||
import type { ServerApiClient } from './server-api-client';
|
||||
import {
|
||||
entryKey,
|
||||
entryPath,
|
||||
parseMap,
|
||||
type PendingEntry,
|
||||
splitEditKey,
|
||||
} from './server-user-data-entries';
|
||||
import { readUnsentWrites, saveUnsentWrites } from './server-unsent-writes';
|
||||
|
||||
/** Server-mode cache keys; the legacy unprefixed keys are never touched. */
|
||||
export const SERVER_CACHE_PREFIX = 'iptvnator-server:';
|
||||
const FLUSH_DELAY_MS = 500;
|
||||
|
||||
interface PendingEntry {
|
||||
readonly namespace: ServerUserStateNamespace;
|
||||
readonly playlistId: string;
|
||||
/** `undefined` deletes the entry. */
|
||||
readonly value: unknown;
|
||||
/** The edit this value came from; a retry never replaces a newer one. */
|
||||
readonly edit: number;
|
||||
}
|
||||
|
||||
type ServerApi = Pick<
|
||||
ServerApiClient,
|
||||
'getJson' | 'sendJson' | 'request' | 'sendKeepalive'
|
||||
@@ -42,7 +41,8 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
private sending: Promise<void> = Promise.resolve();
|
||||
private writesInFlight = 0;
|
||||
/** Page-hide writes still travelling, by `namespace|playlistId`. */
|
||||
private readonly keepaliveKeys = new Set<string>();
|
||||
private readonly keepalive = new Map<string, PendingEntry>();
|
||||
private unsentSaved = 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. */
|
||||
@@ -53,7 +53,19 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
private readonly api: ServerApi,
|
||||
private readonly local: UserDataStorage,
|
||||
private readonly onWriteFailure: (error: unknown) => void
|
||||
) {}
|
||||
) {
|
||||
// Writes a previous visit could not confirm go out with the first
|
||||
// flush, and the first read keeps them.
|
||||
for (const write of readUnsentWrites(local)) {
|
||||
this.queue(write.namespace, write.playlistId, write.value);
|
||||
}
|
||||
this.unsentSaved = this.pending.size > 0;
|
||||
}
|
||||
|
||||
/** Whether a write is queued, being retried, or still travelling. */
|
||||
hasUnsentWrites(): boolean {
|
||||
return this.pending.size > 0 || this.keepalive.size > 0;
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.isSynced(key)
|
||||
@@ -110,7 +122,7 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
);
|
||||
const unsaved = new Set([
|
||||
...this.pending.keys(),
|
||||
...this.keepaliveKeys,
|
||||
...this.keepalive.keys(),
|
||||
]);
|
||||
for (const [key, edit] of this.edits) {
|
||||
if (edit > editsBefore) {
|
||||
@@ -144,7 +156,7 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
if (
|
||||
edit <= editsBefore &&
|
||||
!this.pending.has(key) &&
|
||||
!this.keepaliveKeys.has(key)
|
||||
!this.keepalive.has(key)
|
||||
) {
|
||||
this.edits.delete(key);
|
||||
}
|
||||
@@ -192,6 +204,10 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
this.writesInFlight -= 1;
|
||||
}
|
||||
};
|
||||
for (const entry of entries) {
|
||||
this.keepalive.set(entryKey(entry), entry);
|
||||
}
|
||||
this.saveUnsent();
|
||||
// Idle: send synchronously, while the hiding page still runs.
|
||||
const run =
|
||||
this.writesInFlight === 0 ? sendAll() : this.sending.then(sendAll);
|
||||
@@ -288,16 +304,15 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
}
|
||||
|
||||
private async sendKeepalive(entry: PendingEntry): Promise<void> {
|
||||
const key = entryKey(entry);
|
||||
const path = entryPath(entry);
|
||||
this.keepaliveKeys.add(key);
|
||||
const ok = await (entry.value === undefined
|
||||
? this.api.sendKeepalive('DELETE', path)
|
||||
: this.api.sendKeepalive('PUT', path, { value: entry.value }));
|
||||
this.keepaliveKeys.delete(key);
|
||||
this.keepalive.delete(entryKey(entry));
|
||||
if (!ok) {
|
||||
this.requeue(entry);
|
||||
}
|
||||
this.saveUnsent();
|
||||
}
|
||||
|
||||
private async send(entry: PendingEntry): Promise<void> {
|
||||
@@ -309,33 +324,21 @@ export class ServerUserDataStorage implements UserDataStorage {
|
||||
value: entry.value,
|
||||
});
|
||||
}
|
||||
this.saveUnsent();
|
||||
} catch (error) {
|
||||
this.requeue(entry);
|
||||
this.saveUnsent();
|
||||
this.onWriteFailure(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function entryKey(entry: PendingEntry): string {
|
||||
return `${entry.namespace}|${entry.playlistId}`;
|
||||
}
|
||||
|
||||
function splitEditKey(key: string): [string, string] {
|
||||
const separator = key.indexOf('|');
|
||||
return [key.slice(0, separator), key.slice(separator + 1)];
|
||||
}
|
||||
|
||||
function entryPath(entry: PendingEntry): string {
|
||||
return `/user-state/${entry.namespace}/${encodeURIComponent(entry.playlistId)}`;
|
||||
}
|
||||
|
||||
function parseMap(value: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
/** Persists what the server has not confirmed; skips idle rewrites. */
|
||||
private saveUnsent(): void {
|
||||
const writes = [...this.pending.values(), ...this.keepalive.values()];
|
||||
if (writes.length === 0 && !this.unsentSaved) {
|
||||
return;
|
||||
}
|
||||
saveUnsentWrites(this.local, writes);
|
||||
this.unsentSaved = writes.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,14 @@
|
||||
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in 3 runs"
|
||||
},
|
||||
"renderer.domMutationsToFirstCard": {
|
||||
"value": 569,
|
||||
"value": 571,
|
||||
"unit": "mutations",
|
||||
"slack": 0,
|
||||
"updatedAt": "2026-10-10",
|
||||
"evidencePr": 1871,
|
||||
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/38045444230",
|
||||
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in 3 runs"
|
||||
"updatedAt": "2026-10-12",
|
||||
"evidencePr": 1903,
|
||||
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/38206088310",
|
||||
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), CI run on PR 1903",
|
||||
"note": "Raised by 2 for self-hosted server storage: the rail server shortcut and the workspace offline banner each leave one @if anchor comment in every build, also when server storage is inactive."
|
||||
},
|
||||
"main.modulesRegisteredBeforeWindow": {
|
||||
"value": 2,
|
||||
|
||||
Reference in new issue
Block a user