test(performance): add end-to-end Xtream benchmark harness (#1300)

* docs(performance): plan Xtream benchmark

* feat(xtream-mock-server): add deterministic 100k fixture

* style(xtream-mock-server): apply repository formatting

* fix(xtream-mock-server): harden performance fixture data

* feat(xtream-mock-server): add performance control plane

* docs(performance): correct Xtream capture plan

* fix(xtream-mock-server): harden performance controls

* fix(xtream-mock-server): harden control lifecycle

* feat(performance): add Xtream preload markers

* feat(performance): trace Xtream main phases

* feat(performance): mark Xtream store publications

* feat(performance): trace Xtream database phases

* feat(performance): trace Xtream delete cancellation

* feat(performance): capture Xtream phase attribution

* feat(performance): mark Sources Xtream refresh

* test(performance): define Xtream benchmark evidence contracts

* test(performance): add Xtream benchmark runner

* test(performance): surface failure evidence writes

* test(performance): align database read clock

* test(performance): preserve capture failure contracts
This commit is contained in:
4gray authored and GitHub committed 2026-07-28 08:08:07 +02:00
1 parent 5e725a06f3
commit a2fafcfc08
256 files changed
+43760 -883

No files matched your search

@@ -3,6 +3,7 @@ import type {
Playlist,
PlaylistRefreshPayload,
PreloadPerformanceMarker,
XtreamPreloadPerformanceMarker,
} from '@iptvnator/shared/interfaces';
export const PRELOAD_ENVIRONMENT = {
@@ -31,6 +32,7 @@ export interface PreloadPerformanceHarness {
getApi: () => ExposedElectronApi;
getMarkers: () => PreloadPerformanceMarker[];
getSentPayloads: <T>(channel: string) => T[];
getXtreamMarkers: () => XtreamPreloadPerformanceMarker[];
ipcRenderer: MockIpcRenderer;
load: (environment?: PreloadEnvironment) => Promise<void>;
}
@@ -130,6 +132,10 @@ export function createPreloadPerformanceHarness(): PreloadPerformanceHarness {
'IPTVNATOR_PERF_CAPTURE_MARKER'
),
getSentPayloads,
getXtreamMarkers: () =>
getSentPayloads<XtreamPreloadPerformanceMarker>(
'IPTVNATOR_XTREAM_PERF_CAPTURE_MARKER'
),
ipcRenderer,
load: async (environment = {}) => {
jest.resetModules();
@@ -15,6 +15,11 @@ import {
createPreloadPerformanceCapture,
toPreloadPerformanceTargetMethod,
} from './preload-performance-capture';
import {
createXtreamPreloadPerformanceCapture,
isXtreamPreloadPerformanceCaptureEnabled,
toXtreamPreloadPerformanceTargetMethod,
} from './xtream-preload-performance-capture';
import type {
EmbeddedMpvBounds,
EmbeddedMpvRecordingStartOptions,
@@ -78,10 +83,17 @@ const dbSaveContentProgressListeners = new Set<
const shouldTraceRendererApi = isRendererApiTraceEnabled();
const shouldCapturePerformance = isPerformanceCaptureEnabled();
const shouldCaptureXtreamPerformance =
isXtreamPreloadPerformanceCaptureEnabled();
const preloadPerformanceCapture = createPreloadPerformanceCapture(
shouldCapturePerformance,
(channel, marker) => ipcRenderer.send(channel, marker)
);
const xtreamPreloadPerformanceCapture = shouldCaptureXtreamPerformance
? createXtreamPreloadPerformanceCapture(true, (channel, marker) =>
ipcRenderer.send(channel, marker)
)
: null;
function emitRendererTrace(payload: {
method: string;
@@ -105,12 +117,18 @@ function wrapElectronApi<T extends object>(api: T): T {
return Object.fromEntries(
Object.entries(api).map(([name, value]) => {
const isPreloadPerformanceTarget =
toPreloadPerformanceTargetMethod(name) !== null;
const isXtreamPreloadPerformanceTarget =
shouldCaptureXtreamPerformance &&
toXtreamPreloadPerformanceTargetMethod(name) !== null;
if (
typeof value !== 'function' ||
name.startsWith('on') ||
name.startsWith('remove') ||
(!shouldTraceRendererApi &&
!toPreloadPerformanceTargetMethod(name))
!isPreloadPerformanceTarget &&
!isXtreamPreloadPerformanceTarget)
) {
return [name, value];
}
@@ -134,6 +152,9 @@ function wrapElectronApi<T extends object>(api: T): T {
name,
args
);
const xtreamPerformanceCall =
xtreamPreloadPerformanceCapture?.start(name, args) ??
null;
try {
const result = original(...args);
@@ -162,6 +183,9 @@ function wrapElectronApi<T extends object>(api: T): T {
performanceCall,
resolvedValue
);
xtreamPreloadPerformanceCapture?.success(
xtreamPerformanceCall
);
return resolvedValue;
})
.catch((error: unknown) => {
@@ -179,6 +203,9 @@ function wrapElectronApi<T extends object>(api: T): T {
preloadPerformanceCapture.error(
performanceCall
);
xtreamPreloadPerformanceCapture?.error(
xtreamPerformanceCall
);
throw error;
});
}
@@ -198,6 +225,9 @@ function wrapElectronApi<T extends object>(api: T): T {
performanceCall,
result
);
xtreamPreloadPerformanceCapture?.success(
xtreamPerformanceCall
);
return result;
} catch (error) {
@@ -213,6 +243,9 @@ function wrapElectronApi<T extends object>(api: T): T {
});
}
preloadPerformanceCapture.error(performanceCall);
xtreamPreloadPerformanceCapture?.error(
xtreamPerformanceCall
);
throw error;
}
},
@@ -738,11 +771,7 @@ const electronApi: ElectronBridgeApi = {
kind: 'all' | 'vod' | 'series',
limit?: number,
playlistType?:
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url'
'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'
) =>
ipcRenderer.invoke(
'DB_GET_GLOBAL_RECENTLY_ADDED',
@@ -0,0 +1,187 @@
import {
PRELOAD_PERFORMANCE_MARKER_CHANNEL,
XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL,
type XtreamPreloadPerformanceMarker,
} from '@iptvnator/shared/interfaces';
import {
createPreloadPerformanceHarness,
createRefreshPayload,
PRELOAD_ENVIRONMENT,
type PreloadPerformanceHarness,
} from './main.preload.performance.test-helpers';
import {
XTREAM_PRELOAD_REQUEST_PAYLOAD,
XTREAM_PRELOAD_TARGET_CASES,
} from './main.preload.xtream-performance.spec-data';
const MARKER_KEYS = [
'action',
'boundary',
'categoryType',
'contentType',
'ipcCallId',
'itemCount',
'method',
'operationId',
'outcome',
'playlistId',
'schemaVersion',
'sessionId',
'sourceEpochMs',
] as const;
describe('main preload Xtream performance marker contract', () => {
let harness: PreloadPerformanceHarness;
beforeEach(() => {
harness = createPreloadPerformanceHarness();
});
afterEach(() => {
harness.cleanup();
jest.restoreAllMocks();
});
it.each(XTREAM_PRELOAD_TARGET_CASES)(
'marks $method without changing IPC arguments or result identity',
async ({ expectedInvoke, expectedMetadata, invoke, method }) => {
const result = new Proxy({}, { get: () => undefined });
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
harness.ipcRenderer.invoke.mockResolvedValueOnce(result);
await expect(
Promise.resolve(invoke(harness.getApi()))
).resolves.toBe(result);
expect(harness.ipcRenderer.invoke).toHaveBeenCalledTimes(1);
expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith(
...expectedInvoke
);
const markers = harness.getXtreamMarkers();
expect(markers).toHaveLength(2);
expect(markers).toEqual([
expect.objectContaining({
...expectedMetadata,
boundary: 'start',
ipcCallId: 1,
method,
outcome: null,
schemaVersion: 1,
}),
expect.objectContaining({
...expectedMetadata,
boundary: 'end',
ipcCallId: 1,
method,
outcome: 'success',
schemaVersion: 1,
}),
]);
expectFixedMarkerShape(markers);
}
);
it('preserves exact rejection and synchronous throw identities', async () => {
const rejection = new Error('provider-error-secret');
const synchronousError = new Error('provider-sync-secret');
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection);
await expect(
harness.getApi().dbGetContent('playlist-1', 'live')
).rejects.toBe(rejection);
expect(
harness.getXtreamMarkers().map(({ outcome }) => outcome)
).toEqual([null, 'error']);
harness.ipcRenderer.invoke.mockImplementationOnce(() => {
throw synchronousError;
});
expect(() => harness.getApi().dbCancelOperation('operation-1')).toThrow(
synchronousError
);
expect(
harness.getXtreamMarkers().map(({ outcome }) => outcome)
).toEqual([null, 'error', null, 'error']);
});
it('preserves a synchronous target return identity', async () => {
const result = { synchronous: true };
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
harness.ipcRenderer.invoke.mockReturnValueOnce(result);
const actual = harness
.getApi()
.dbCancelOperation('operation-1') as unknown;
expect(actual).toBe(result);
expect(
harness.getXtreamMarkers().map(({ outcome }) => outcome)
).toEqual([null, 'success']);
});
it('keeps M3U and Xtream protocols on their own channels', async () => {
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
await harness.getApi().refreshPlaylist(createRefreshPayload());
await harness.getApi().xtreamRequest(XTREAM_PRELOAD_REQUEST_PAYLOAD);
expect(harness.getMarkers()).toHaveLength(2);
expect(harness.getXtreamMarkers()).toHaveLength(2);
expect(
harness.getSentPayloads(PRELOAD_PERFORMANCE_MARKER_CHANNEL)
).toEqual(harness.getMarkers());
expect(
harness.getSentPayloads(XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL)
).toEqual(harness.getXtreamMarkers());
expect(
harness
.getMarkers()
.every(({ method }) => method === 'refreshPlaylist')
).toBe(true);
expect(
harness
.getXtreamMarkers()
.every(({ method }) => method === 'xtreamRequest')
).toBe(true);
});
});
function expectFixedMarkerShape(
markers: XtreamPreloadPerformanceMarker[]
): void {
for (const [index, marker] of markers.entries()) {
expect(Object.keys(marker).sort()).toEqual([...MARKER_KEYS].sort());
expect(Number.isFinite(marker.sourceEpochMs)).toBe(true);
if (index > 0) {
expect(marker.sourceEpochMs).toBeGreaterThanOrEqual(
markers[index - 1].sourceEpochMs
);
}
}
const serialized = JSON.stringify(markers);
for (const forbidden of [
'provider-password-secret',
'provider-user-secret',
'provider-url-secret',
'request-secret',
'secret-title',
'secret-stream-title',
'secret-favorite-title',
'secret-recent-title',
'provider-search-secret',
'global-search-secret',
'secret-source',
'provider-error-secret',
]) {
expect(serialized).not.toContain(forbidden);
}
}
@@ -0,0 +1,180 @@
import { DEBUG_TRACE_EVENT_CHANNEL } from '../services/debug-trace';
import {
createPreloadPerformanceHarness,
PRELOAD_ENVIRONMENT,
type PreloadPerformanceHarness,
} from './main.preload.performance.test-helpers';
describe('main preload Xtream performance marker gates', () => {
let harness: PreloadPerformanceHarness;
beforeEach(() => {
harness = createPreloadPerformanceHarness();
});
afterEach(() => {
harness.cleanup();
jest.restoreAllMocks();
});
it.each([
undefined,
'',
'0',
'false',
'true',
' TRUE ',
'yes',
'On',
' 1 ',
])(
'requires the exact performance flag value 1, not %p',
async (flagValue) => {
await harness.load(
flagValue === undefined
? {}
: {
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue,
}
);
await harness.getApi().dbGetContent('playlist-1', 'live');
expect(harness.getXtreamMarkers()).toEqual([]);
}
);
it('enables the Xtream marker only for the exact value 1', async () => {
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
await harness.getApi().dbGetContent('playlist-1', 'live');
expect(harness.getXtreamMarkers()).toHaveLength(2);
});
it('does no Xtream clock or metadata work while its exact gate is disabled', async () => {
const clock = jest
.spyOn(globalThis.performance, 'now')
.mockImplementation(() => {
throw new Error('clock must stay idle');
});
const payload = new Proxy(
{},
{
get: () => {
throw new Error('metadata must stay opaque');
},
}
);
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: 'true',
});
await expect(
harness.getApi().xtreamRequest(payload as never)
).resolves.toEqual({ success: true });
expect(clock).not.toHaveBeenCalled();
expect(harness.getXtreamMarkers()).toEqual([]);
expect(harness.ipcRenderer.invoke.mock.calls[0][0]).toBe(
'XTREAM_REQUEST'
);
expect(harness.ipcRenderer.invoke.mock.calls[0][1]).toBe(payload);
});
it('does not inspect or mark non-target methods in performance-only mode', async () => {
const settings = new Proxy(
{},
{
get: () => {
throw new Error('non-target data must stay opaque');
},
}
);
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
await harness.getApi().updateSettings(settings);
expect(harness.getXtreamMarkers()).toEqual([]);
expect(harness.ipcRenderer.invoke.mock.calls[0][0]).toBe(
'SETTINGS_UPDATE'
);
expect(harness.ipcRenderer.invoke.mock.calls[0][1]).toBe(settings);
});
it('keeps trace-only Xtream calls on the existing debug channel', async () => {
await harness.load({
[PRELOAD_ENVIRONMENT.TRACE_IPC]: '1',
});
await harness.getApi().dbGetContent('playlist-1', 'live');
expect(
harness
.getSentPayloads<Record<string, unknown>>(
DEBUG_TRACE_EVENT_CHANNEL
)
.map(({ method, phase }) => ({ method, phase }))
).toEqual([
{ method: 'dbGetContent', phase: 'start' },
{ method: 'dbGetContent', phase: 'success' },
]);
expect(harness.getXtreamMarkers()).toEqual([]);
});
it('keeps both debug tracing and Xtream markers when both exact gates are enabled', async () => {
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
[PRELOAD_ENVIRONMENT.TRACE_IPC]: '1',
});
await harness.getApi().dbGetContent('playlist-1', 'live');
expect(harness.getSentPayloads(DEBUG_TRACE_EVENT_CHANNEL)).toHaveLength(
2
);
expect(harness.getXtreamMarkers()).toHaveLength(2);
});
it('keeps the bridge fail-neutral when marker delivery fails', async () => {
const result = { success: true };
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
harness.ipcRenderer.invoke.mockResolvedValueOnce(result);
harness.ipcRenderer.send.mockImplementation((channel: string) => {
if (channel === 'IPTVNATOR_XTREAM_PERF_CAPTURE_MARKER') {
throw new Error('marker sink failure');
}
});
await expect(
harness.getApi().dbGetContent('playlist-1', 'live')
).resolves.toBe(result);
expect(harness.ipcRenderer.invoke).toHaveBeenCalledTimes(1);
});
it('keeps the bridge fail-neutral when the high-resolution clock fails', async () => {
const result = { success: true };
await harness.load({
[PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1',
});
harness.ipcRenderer.invoke.mockResolvedValueOnce(result);
jest.spyOn(globalThis.performance, 'now').mockImplementation(() => {
throw new Error('clock failure');
});
await expect(
harness.getApi().dbGetContent('playlist-1', 'live')
).resolves.toBe(result);
expect(
harness
.getXtreamMarkers()
.every(({ sourceEpochMs }) => Number.isFinite(sourceEpochMs))
).toBe(true);
});
});
@@ -0,0 +1,230 @@
import {
XtreamCodeActions,
type XtreamPreloadPerformanceMarker,
} from '@iptvnator/shared/interfaces';
import type { ExposedElectronApi } from './main.preload.performance.test-helpers';
type ExpectedMetadata = Pick<
XtreamPreloadPerformanceMarker,
| 'action'
| 'categoryType'
| 'contentType'
| 'itemCount'
| 'operationId'
| 'playlistId'
| 'sessionId'
>;
const EMPTY_METADATA: ExpectedMetadata = {
action: null,
categoryType: null,
contentType: null,
itemCount: null,
operationId: null,
playlistId: null,
sessionId: null,
};
export const XTREAM_PRELOAD_REQUEST_PAYLOAD = {
params: {
action: XtreamCodeActions.GetLiveStreams,
password: 'provider-password-secret',
username: 'provider-user-secret',
},
requestId: 'request-secret',
sessionId: 'session-1',
suppressErrorLog: true,
url: 'http://provider-url-secret',
};
const categoryItems = [
{
category_id: '1',
category_name: 'secret-title',
parent_id: 0,
},
];
const streamItems = [{ name: 'secret-stream-title', stream_id: 1 }];
const favorites = [{ title: 'secret-favorite-title' }];
const recent = [{ title: 'secret-recent-title' }, {}];
const globalSources = [{ playlistId: 'secret-source' }];
const globalOptions = { limit: 25 };
interface TargetCase {
expectedInvoke: readonly unknown[];
expectedMetadata: ExpectedMetadata;
invoke: (api: ExposedElectronApi) => unknown;
method: string;
}
export const XTREAM_PRELOAD_TARGET_CASES: TargetCase[] = [
{
expectedInvoke: ['XTREAM_REQUEST', XTREAM_PRELOAD_REQUEST_PAYLOAD],
expectedMetadata: {
...EMPTY_METADATA,
action: XtreamCodeActions.GetLiveStreams,
sessionId: 'session-1',
},
invoke: (api) => api.xtreamRequest(XTREAM_PRELOAD_REQUEST_PAYLOAD),
method: 'xtreamRequest',
},
{
expectedInvoke: ['XTREAM_CANCEL_SESSION', 'session-2'],
expectedMetadata: { ...EMPTY_METADATA, sessionId: 'session-2' },
invoke: (api) => api.xtreamCancelSession('session-2'),
method: 'xtreamCancelSession',
},
{
expectedInvoke: ['DB_GET_CATEGORIES', 'playlist-1', 'movies'],
expectedMetadata: {
...EMPTY_METADATA,
categoryType: 'movies',
playlistId: 'playlist-1',
},
invoke: (api) => api.dbGetCategories('playlist-1', 'movies'),
method: 'dbGetCategories',
},
{
expectedInvoke: [
'DB_SAVE_CATEGORIES',
'playlist-2',
categoryItems,
'live',
[91],
],
expectedMetadata: {
...EMPTY_METADATA,
categoryType: 'live',
itemCount: 1,
playlistId: 'playlist-2',
},
invoke: (api) =>
api.dbSaveCategories('playlist-2', categoryItems, 'live', [91]),
method: 'dbSaveCategories',
},
{
expectedInvoke: ['DB_GET_CONTENT', 'playlist-3', 'series'],
expectedMetadata: {
...EMPTY_METADATA,
contentType: 'series',
playlistId: 'playlist-3',
},
invoke: (api) => api.dbGetContent('playlist-3', 'series'),
method: 'dbGetContent',
},
{
expectedInvoke: [
'DB_SAVE_CONTENT',
'playlist-4',
streamItems,
'movie',
'operation-4',
],
expectedMetadata: {
...EMPTY_METADATA,
contentType: 'movie',
itemCount: 1,
operationId: 'operation-4',
playlistId: 'playlist-4',
},
invoke: (api) =>
api.dbSaveContent(
'playlist-4',
streamItems as never,
'movie',
'operation-4'
),
method: 'dbSaveContent',
},
{
expectedInvoke: [
'DB_DELETE_XTREAM_CONTENT',
'playlist-5',
'operation-5',
],
expectedMetadata: {
...EMPTY_METADATA,
operationId: 'operation-5',
playlistId: 'playlist-5',
},
invoke: (api) => api.dbDeleteXtreamContent('playlist-5', 'operation-5'),
method: 'dbDeleteXtreamContent',
},
{
expectedInvoke: ['DB_DELETE_PLAYLIST', 'playlist-6', 'operation-6'],
expectedMetadata: {
...EMPTY_METADATA,
operationId: 'operation-6',
playlistId: 'playlist-6',
},
invoke: (api) => api.dbDeletePlaylist('playlist-6', 'operation-6'),
method: 'dbDeletePlaylist',
},
{
expectedInvoke: [
'DB_RESTORE_XTREAM_USER_DATA',
'playlist-7',
favorites,
recent,
'operation-7',
],
expectedMetadata: {
...EMPTY_METADATA,
itemCount: 3,
operationId: 'operation-7',
playlistId: 'playlist-7',
},
invoke: (api) =>
api.dbRestoreXtreamUserData(
'playlist-7',
favorites as never,
recent as never,
'operation-7'
),
method: 'dbRestoreXtreamUserData',
},
{
expectedInvoke: [
'DB_SEARCH_CONTENT',
'playlist-8',
'provider-search-secret',
['live'],
true,
],
expectedMetadata: { ...EMPTY_METADATA, playlistId: 'playlist-8' },
invoke: (api) =>
api.dbSearchContent(
'playlist-8',
'provider-search-secret',
['live'],
true
),
method: 'dbSearchContent',
},
{
expectedInvoke: [
'DB_GLOBAL_SEARCH',
'global-search-secret',
['live'],
true,
globalSources,
globalOptions,
],
expectedMetadata: EMPTY_METADATA,
invoke: (api) =>
api.dbGlobalSearch(
'global-search-secret',
['live'],
true,
globalSources as never,
globalOptions
),
method: 'dbGlobalSearch',
},
{
expectedInvoke: ['DB_CANCEL_OPERATION', 'operation-9'],
expectedMetadata: { ...EMPTY_METADATA, operationId: 'operation-9' },
invoke: (api) => api.dbCancelOperation('operation-9'),
method: 'dbCancelOperation',
},
];
@@ -0,0 +1,127 @@
import type { XtreamPreloadPerformanceMarker } from '@iptvnator/shared/interfaces';
import { createXtreamPreloadPerformanceCapture } from './xtream-preload-performance-capture';
describe('Xtream preload performance capture runtime', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('does zero target metadata or clock work when disabled', () => {
const clock = jest.fn(() => {
throw new Error('clock must not run');
});
const sendMarker = jest.fn();
const args = new Proxy([{}], {
get: () => {
throw new Error('metadata must not run');
},
});
const capture = createXtreamPreloadPerformanceCapture(
false,
sendMarker,
clock
);
expect(capture.start('xtreamRequest', args)).toBeNull();
expect(capture.start('updateSettings', args)).toBeNull();
capture.success(null);
capture.error(null);
expect(clock).not.toHaveBeenCalled();
expect(sendMarker).not.toHaveBeenCalled();
});
it('keeps calls independent with positive ids and nondecreasing finite timestamps', () => {
const markers: XtreamPreloadPerformanceMarker[] = [];
const clockValues = [10.5, 9.5, Number.NaN, 11.25];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => clockValues.shift() as number
);
const first = capture.start('dbGetContent', ['playlist-1', 'live']);
const second = capture.start('dbGetContent', ['playlist-2', 'movie']);
capture.success(second);
capture.error(first);
expect(
markers.map(({ boundary, ipcCallId, outcome }) => ({
boundary,
ipcCallId,
outcome,
}))
).toEqual([
{ boundary: 'start', ipcCallId: 1, outcome: null },
{ boundary: 'start', ipcCallId: 2, outcome: null },
{ boundary: 'end', ipcCallId: 2, outcome: 'success' },
{ boundary: 'end', ipcCallId: 1, outcome: 'error' },
]);
expect(
markers.every(({ sourceEpochMs }) => Number.isFinite(sourceEpochMs))
).toBe(true);
expect(markers.map(({ sourceEpochMs }) => sourceEpochMs)).toEqual([
10.5, 10.5, 10.5, 11.25,
]);
});
it.each([
['non-finite', () => Number.NaN],
[
'throwing',
() => {
throw new Error('clock failure');
},
],
])(
'uses a positive finite epoch fallback for a first %s clock sample',
(_description, clock) => {
jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
clock
);
capture.start('dbGetContent', ['playlist-1', 'live']);
expect(markers[0].sourceEpochMs).toBe(1_700_000_000_000);
expect(markers[0].sourceEpochMs).toBeGreaterThan(0);
expect(Number.isFinite(markers[0].sourceEpochMs)).toBe(true);
}
);
it('swallows sink and clock failures without inspecting result or error values', () => {
const result = new Proxy(
{},
{
get: () => {
throw new Error('result must stay opaque');
},
}
);
const error = new Proxy(
{},
{
get: () => {
throw new Error('error must stay opaque');
},
}
);
const capture = createXtreamPreloadPerformanceCapture(
true,
() => {
throw new Error('sink failure');
},
() => {
throw new Error('clock failure');
}
);
const call = capture.start('dbGetContent', ['playlist-1', 'live']);
expect(() => capture.success(call, result)).not.toThrow();
expect(() => capture.error(call, error)).not.toThrow();
});
});
@@ -0,0 +1,230 @@
import type { XtreamPreloadPerformanceMarker } from '@iptvnator/shared/interfaces';
import { createXtreamPreloadPerformanceCapture } from './xtream-preload-performance-capture';
describe('Xtream preload performance capture safety', () => {
it.each([
['', null],
[' ', null],
['-leading-dash', null],
['contains space', null],
['contains/slash', null],
['x'.repeat(129), null],
[' playlist.ok:1 ', null],
['playlist.ok:1', 'playlist.ok:1'],
['A', 'A'],
])('normalizes identifier %p to %p', (identifier, expected) => {
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 1_000
);
capture.start('dbDeletePlaylist', [identifier, identifier]);
expect(markers[0]).toMatchObject({
operationId: expected,
playlistId: expected,
});
});
it.each([
['xtreamRequest', [{ params: { action: 'provider-action-secret' } }]],
['dbGetCategories', ['playlist-1', 'vod']],
['dbGetCategories', ['playlist-1', 'LIVE']],
['dbGetContent', ['playlist-1', 'movies']],
['dbGetContent', ['playlist-1', 'vod']],
] as const)(
'turns invalid action or type metadata into null for %s',
(method, args) => {
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 1_000
);
capture.start(method, args);
expect(markers[0]).toMatchObject({
action: null,
categoryType: null,
contentType: null,
});
expect(JSON.stringify(markers)).not.toContain(
'provider-action-secret'
);
}
);
it('never enumerates provider payloads, reads array entries, or records forbidden strings', () => {
const forbidden = [
'provider-url-secret',
'provider-user-secret',
'provider-password-secret',
'provider-title-secret',
'provider-search-secret',
'provider-result-secret',
'provider-error-secret',
];
const params = Object.defineProperties(
{ action: 'get_series' },
{
password: {
enumerable: true,
get: () => {
throw new Error(forbidden[2]);
},
},
username: {
enumerable: true,
get: () => {
throw new Error(forbidden[1]);
},
},
}
);
const payload = {
params,
sessionId: 'session-1',
title: forbidden[3],
url: forbidden[0],
};
const entryRead = jest.fn(() => {
throw new Error('array entries must stay opaque');
});
const streams = new Array<unknown>(1);
Object.defineProperty(streams, 0, { get: entryRead });
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 1_000
);
const request = capture.start('xtreamRequest', [payload]);
capture.success(request);
const save = capture.start('dbSaveContent', [
'playlist-1',
streams,
'live',
'operation-1',
]);
capture.error(save);
capture.start('dbSearchContent', [
'playlist-1',
forbidden[4],
['live'],
]);
capture.start('dbGlobalSearch', [
forbidden[4],
['live'],
false,
[{ title: forbidden[3] }],
]);
expect(entryRead).not.toHaveBeenCalled();
const serialized = JSON.stringify(markers);
for (const value of forbidden) {
expect(serialized).not.toContain(value);
}
});
it('swallows hostile argument, payload, params, and array length traps', () => {
const throwingProxy = new Proxy(
{},
{
get: () => {
throw new Error('hostile getter');
},
}
);
const hostileArray = new Proxy([], {
get: () => {
throw new Error('hostile array length');
},
});
const hostileArgs = new Proxy([throwingProxy], {
get: () => {
throw new Error('hostile args');
},
});
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 1_000
);
expect(() => capture.start('xtreamRequest', hostileArgs)).not.toThrow();
expect(() =>
capture.start('dbSaveCategories', [
'playlist-1',
hostileArray,
'live',
])
).not.toThrow();
expect(markers).toHaveLength(2);
expect(markers[0]).toMatchObject({
action: null,
sessionId: null,
});
expect(markers[1].itemCount).toBeNull();
});
it('swallows revoked array proxies without changing the call', () => {
const { proxy, revoke } = Proxy.revocable([], {});
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 1_000
);
revoke();
expect(() =>
capture.start('dbSaveContent', [
'playlist-1',
proxy,
'live',
'operation-1',
])
).not.toThrow();
expect(markers[0].itemCount).toBeNull();
});
it('accepts only bounded array lengths and safely sums restore counts', () => {
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 1_000
);
capture.start('dbSaveCategories', [
'playlist-1',
new Array(1_000_001),
'live',
]);
capture.start('dbRestoreXtreamUserData', [
'playlist-1',
new Array(600_000),
new Array(400_000),
'operation-1',
]);
capture.start('dbRestoreXtreamUserData', [
'playlist-1',
new Array(600_000),
new Array(400_001),
'operation-1',
]);
expect(markers.map(({ itemCount }) => itemCount)).toEqual([
null,
1_000_000,
null,
]);
});
});
@@ -0,0 +1,219 @@
import {
XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL,
XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION,
type XtreamPreloadPerformanceMarker,
} from '@iptvnator/shared/interfaces';
import { XtreamCodeActions } from '@iptvnator/shared/interfaces';
import { createXtreamPreloadPerformanceCapture } from './xtream-preload-performance-capture';
const MARKER_KEYS = [
'action',
'boundary',
'categoryType',
'contentType',
'ipcCallId',
'itemCount',
'method',
'operationId',
'outcome',
'playlistId',
'schemaVersion',
'sessionId',
'sourceEpochMs',
] as const;
const EMPTY_METADATA = {
action: null,
categoryType: null,
contentType: null,
itemCount: null,
operationId: null,
playlistId: null,
sessionId: null,
} as const;
describe('Xtream preload performance capture contract', () => {
it.each([
{
args: [
{
params: { action: XtreamCodeActions.GetLiveStreams },
sessionId: 'session-1',
},
],
expected: {
...EMPTY_METADATA,
action: XtreamCodeActions.GetLiveStreams,
sessionId: 'session-1',
},
method: 'xtreamRequest',
},
{
args: ['session-2'],
expected: { ...EMPTY_METADATA, sessionId: 'session-2' },
method: 'xtreamCancelSession',
},
{
args: ['playlist-1', 'movies'],
expected: {
...EMPTY_METADATA,
categoryType: 'movies',
playlistId: 'playlist-1',
},
method: 'dbGetCategories',
},
{
args: ['playlist-2', [{}, {}, {}], 'live', [99]],
expected: {
...EMPTY_METADATA,
categoryType: 'live',
itemCount: 3,
playlistId: 'playlist-2',
},
method: 'dbSaveCategories',
},
{
args: ['playlist-3', 'series'],
expected: {
...EMPTY_METADATA,
contentType: 'series',
playlistId: 'playlist-3',
},
method: 'dbGetContent',
},
{
args: ['playlist-4', [{}, {}], 'movie', 'operation-4'],
expected: {
...EMPTY_METADATA,
contentType: 'movie',
itemCount: 2,
operationId: 'operation-4',
playlistId: 'playlist-4',
},
method: 'dbSaveContent',
},
{
args: ['playlist-5', 'operation-5'],
expected: {
...EMPTY_METADATA,
operationId: 'operation-5',
playlistId: 'playlist-5',
},
method: 'dbDeleteXtreamContent',
},
{
args: ['playlist-6', 'operation-6'],
expected: {
...EMPTY_METADATA,
operationId: 'operation-6',
playlistId: 'playlist-6',
},
method: 'dbDeletePlaylist',
},
{
args: ['playlist-7', [{}, {}], [{}], 'operation-7'],
expected: {
...EMPTY_METADATA,
itemCount: 3,
operationId: 'operation-7',
playlistId: 'playlist-7',
},
method: 'dbRestoreXtreamUserData',
},
{
args: ['playlist-8', 'provider search', ['live'], true],
expected: {
...EMPTY_METADATA,
playlistId: 'playlist-8',
},
method: 'dbSearchContent',
},
{
args: ['provider search', ['live'], true, [{ id: 'source-1' }]],
expected: EMPTY_METADATA,
method: 'dbGlobalSearch',
},
{
args: ['operation-9'],
expected: { ...EMPTY_METADATA, operationId: 'operation-9' },
method: 'dbCancelOperation',
},
] as const)(
'extracts only safe positional metadata for $method',
({ args, expected, method }) => {
const sendMarker = jest.fn();
const capture = createXtreamPreloadPerformanceCapture(
true,
sendMarker,
() => 1_000.25
);
const call = capture.start(method, args);
expect(call).not.toBeNull();
expect(sendMarker).toHaveBeenCalledWith(
XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL,
{
...expected,
boundary: 'start',
ipcCallId: 1,
method,
outcome: null,
schemaVersion: XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION,
sourceEpochMs: 1_000.25,
}
);
}
);
it('emits fixed-key start and end markers with matching call metadata', () => {
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 2_000.5
);
const successCall = capture.start('dbGetContent', [
'playlist-1',
'live',
]);
capture.success(successCall);
const errorCall = capture.start('dbCancelOperation', ['operation-2']);
capture.error(errorCall);
expect(
markers.map(({ boundary, ipcCallId, outcome }) => ({
boundary,
ipcCallId,
outcome,
}))
).toEqual([
{ boundary: 'start', ipcCallId: 1, outcome: null },
{ boundary: 'end', ipcCallId: 1, outcome: 'success' },
{ boundary: 'start', ipcCallId: 2, outcome: null },
{ boundary: 'end', ipcCallId: 2, outcome: 'error' },
]);
for (const marker of markers) {
expect(Object.keys(marker).sort()).toEqual([...MARKER_KEYS].sort());
}
});
it('allows every current Xtream action including the legacy alias', () => {
const markers: XtreamPreloadPerformanceMarker[] = [];
const capture = createXtreamPreloadPerformanceCapture(
true,
(_channel, marker) => markers.push(marker),
() => 3_000
);
const actions = Object.values(XtreamCodeActions);
for (const action of actions) {
capture.start('xtreamRequest', [{ params: { action } }]);
}
expect(markers.map(({ action }) => action)).toEqual(actions);
expect(actions).toContain('get_simple_data_table');
expect(actions).toContain('get_simple_date_table');
});
});
@@ -0,0 +1,191 @@
import {
XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL,
XTREAM_PRELOAD_PERFORMANCE_METHOD,
XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION,
type XtreamPreloadPerformanceBoundary,
type XtreamPreloadPerformanceMarker,
type XtreamPreloadPerformanceMethod,
type XtreamPreloadPerformanceOutcome,
} from '@iptvnator/shared/interfaces';
import {
extractXtreamPreloadPerformanceMetadata,
type XtreamPreloadMarkerMetadata,
} from './xtream-preload-performance-metadata';
export interface XtreamPreloadPerformanceCallContext extends XtreamPreloadMarkerMetadata {
ipcCallId: number;
method: XtreamPreloadPerformanceMethod;
}
export interface XtreamPreloadPerformanceCapture {
error: (
call: XtreamPreloadPerformanceCallContext | null,
ignoredError?: unknown
) => void;
start: (
methodName: string,
args: readonly unknown[]
) => XtreamPreloadPerformanceCallContext | null;
success: (
call: XtreamPreloadPerformanceCallContext | null,
ignoredResult?: unknown
) => void;
}
type MarkerSender = (
channel: typeof XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL,
marker: XtreamPreloadPerformanceMarker
) => void;
type EpochReader = () => number;
export function toXtreamPreloadPerformanceTargetMethod(
methodName: string
): XtreamPreloadPerformanceMethod | null {
switch (methodName) {
case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_REQUEST:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_CANCEL_SESSION:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CATEGORIES:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CATEGORIES:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CONTENT:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CONTENT:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_XTREAM_CONTENT:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_PLAYLIST:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_RESTORE_XTREAM_USER_DATA:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SEARCH_CONTENT:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GLOBAL_SEARCH:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_CANCEL_OPERATION:
return methodName;
default:
return null;
}
}
export function isXtreamPreloadPerformanceCaptureEnabled(): boolean {
return process.env['IPTVNATOR_PERF_CAPTURE'] === '1';
}
function readHighResolutionEpochMs(): number {
try {
const timeOrigin = globalThis.performance?.timeOrigin;
const monotonicNow = globalThis.performance?.now?.();
const highResolutionEpoch =
typeof timeOrigin === 'number' && typeof monotonicNow === 'number'
? timeOrigin + monotonicNow
: Number.NaN;
if (Number.isFinite(highResolutionEpoch)) {
return highResolutionEpoch;
}
} catch {
// Fall through to the wall clock.
}
try {
return Date.now();
} catch {
return Number.NaN;
}
}
function readPositiveWallClockEpochMs(): number {
try {
const wallClockEpochMs = Date.now();
return Number.isFinite(wallClockEpochMs) && wallClockEpochMs > 0
? wallClockEpochMs
: 1;
} catch {
return 1;
}
}
function readMonotonicEpochMs(
readEpochMs: EpochReader,
lastSourceEpochMs: number
): number {
let clockValue = Number.NaN;
try {
clockValue = readEpochMs();
} catch {
// Use the last valid sample or the wall clock below.
}
const fallbackEpochMs =
lastSourceEpochMs > 0
? lastSourceEpochMs
: readPositiveWallClockEpochMs();
const sourceEpochMs =
Number.isFinite(clockValue) && clockValue > 0
? clockValue
: fallbackEpochMs;
return Math.max(sourceEpochMs, lastSourceEpochMs);
}
export function createXtreamPreloadPerformanceCapture(
enabled: boolean,
sendMarker: MarkerSender,
readEpochMs: EpochReader = readHighResolutionEpochMs
): XtreamPreloadPerformanceCapture {
let lastSourceEpochMs = 0;
let nextIpcCallId = 1;
function emit(
call: XtreamPreloadPerformanceCallContext,
boundary: XtreamPreloadPerformanceBoundary,
outcome: XtreamPreloadPerformanceOutcome
): void {
const sourceEpochMs = readMonotonicEpochMs(
readEpochMs,
lastSourceEpochMs
);
lastSourceEpochMs = sourceEpochMs;
try {
sendMarker(XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, {
schemaVersion: XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION,
sourceEpochMs,
ipcCallId: call.ipcCallId,
method: call.method,
boundary,
outcome,
playlistId: call.playlistId,
operationId: call.operationId,
sessionId: call.sessionId,
action: call.action,
categoryType: call.categoryType,
contentType: call.contentType,
itemCount: call.itemCount,
});
} catch {
// Performance instrumentation must never affect the bridge call.
}
}
return {
error: (call) => {
if (call) {
emit(call, 'end', 'error');
}
},
start: (methodName, args) => {
if (!enabled) {
return null;
}
const method = toXtreamPreloadPerformanceTargetMethod(methodName);
if (!method || nextIpcCallId > Number.MAX_SAFE_INTEGER) {
return null;
}
const call = {
ipcCallId: nextIpcCallId,
method,
...extractXtreamPreloadPerformanceMetadata(method, args),
};
nextIpcCallId += 1;
emit(call, 'start', null);
return call;
},
success: (call) => {
if (call) {
emit(call, 'end', 'success');
}
},
};
}
@@ -0,0 +1,190 @@
import {
XTREAM_PRELOAD_PERFORMANCE_METHOD,
XtreamCodeActions,
type XtreamPreloadPerformanceCategoryType,
type XtreamPreloadPerformanceContentType,
type XtreamPreloadPerformanceMarker,
type XtreamPreloadPerformanceMethod,
} from '@iptvnator/shared/interfaces';
const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
const MAX_IDENTIFIER_LENGTH = 128;
const MAX_ITEM_COUNT = 1_000_000;
const ALLOWED_ACTIONS = new Set<string>(Object.values(XtreamCodeActions));
const ALLOWED_CATEGORY_TYPES = new Set(['live', 'movies', 'series']);
const ALLOWED_CONTENT_TYPES = new Set(['live', 'movie', 'series']);
export type XtreamPreloadMarkerMetadata = Pick<
XtreamPreloadPerformanceMarker,
| 'action'
| 'categoryType'
| 'contentType'
| 'itemCount'
| 'operationId'
| 'playlistId'
| 'sessionId'
>;
function readValue(value: unknown, property: PropertyKey): unknown {
if (
(typeof value !== 'object' || value === null) &&
typeof value !== 'function'
) {
return undefined;
}
try {
return Reflect.get(value, property);
} catch {
return undefined;
}
}
function readArgument(args: readonly unknown[], index: number): unknown {
return readValue(args, index);
}
function normalizeIdentifier(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
return value.length >= 1 &&
value.length <= MAX_IDENTIFIER_LENGTH &&
IDENTIFIER_PATTERN.test(value)
? value
: null;
}
function normalizeAction(value: unknown): XtreamCodeActions | null {
return typeof value === 'string' && ALLOWED_ACTIONS.has(value)
? (value as XtreamCodeActions)
: null;
}
function normalizeCategoryType(
value: unknown
): XtreamPreloadPerformanceCategoryType | null {
return typeof value === 'string' && ALLOWED_CATEGORY_TYPES.has(value)
? (value as XtreamPreloadPerformanceCategoryType)
: null;
}
function normalizeContentType(
value: unknown
): XtreamPreloadPerformanceContentType | null {
return typeof value === 'string' && ALLOWED_CONTENT_TYPES.has(value)
? (value as XtreamPreloadPerformanceContentType)
: null;
}
function readArrayCount(value: unknown): number | null {
let isArray = false;
try {
isArray = Array.isArray(value);
} catch {
return null;
}
if (!isArray) {
return null;
}
const length = readValue(value, 'length');
return typeof length === 'number' &&
Number.isSafeInteger(length) &&
length >= 0 &&
length <= MAX_ITEM_COUNT
? length
: null;
}
function sumArrayCounts(first: unknown, second: unknown): number | null {
const firstCount = readArrayCount(first);
const secondCount = readArrayCount(second);
if (firstCount === null || secondCount === null) {
return null;
}
const total = firstCount + secondCount;
return Number.isSafeInteger(total) && total <= MAX_ITEM_COUNT
? total
: null;
}
function emptyMetadata(): XtreamPreloadMarkerMetadata {
return {
action: null,
categoryType: null,
contentType: null,
itemCount: null,
operationId: null,
playlistId: null,
sessionId: null,
};
}
export function extractXtreamPreloadPerformanceMetadata(
method: XtreamPreloadPerformanceMethod,
args: readonly unknown[]
): XtreamPreloadMarkerMetadata {
const metadata = emptyMetadata();
const first = readArgument(args, 0);
switch (method) {
case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_REQUEST: {
const params = readValue(first, 'params');
metadata.sessionId = normalizeIdentifier(
readValue(first, 'sessionId')
);
metadata.action = normalizeAction(readValue(params, 'action'));
return metadata;
}
case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_CANCEL_SESSION:
metadata.sessionId = normalizeIdentifier(first);
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CATEGORIES:
metadata.playlistId = normalizeIdentifier(first);
metadata.categoryType = normalizeCategoryType(
readArgument(args, 1)
);
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CATEGORIES:
metadata.playlistId = normalizeIdentifier(first);
metadata.itemCount = readArrayCount(readArgument(args, 1));
metadata.categoryType = normalizeCategoryType(
readArgument(args, 2)
);
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CONTENT:
metadata.playlistId = normalizeIdentifier(first);
metadata.contentType = normalizeContentType(readArgument(args, 1));
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CONTENT:
metadata.playlistId = normalizeIdentifier(first);
metadata.itemCount = readArrayCount(readArgument(args, 1));
metadata.contentType = normalizeContentType(readArgument(args, 2));
metadata.operationId = normalizeIdentifier(readArgument(args, 3));
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_XTREAM_CONTENT:
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_PLAYLIST:
metadata.playlistId = normalizeIdentifier(first);
metadata.operationId = normalizeIdentifier(readArgument(args, 1));
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_RESTORE_XTREAM_USER_DATA:
metadata.playlistId = normalizeIdentifier(first);
metadata.itemCount = sumArrayCounts(
readArgument(args, 1),
readArgument(args, 2)
);
metadata.operationId = normalizeIdentifier(readArgument(args, 3));
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SEARCH_CONTENT:
metadata.playlistId = normalizeIdentifier(first);
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_CANCEL_OPERATION:
metadata.operationId = normalizeIdentifier(first);
return metadata;
case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GLOBAL_SEARCH:
return metadata;
}
}
@@ -0,0 +1,110 @@
import type { AppDatabase } from '../database.types';
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import { getCategories, saveCategories } from './category.operations';
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe('category operation performance phases', () => {
it('keeps the categories read phase open until the query resolves', async () => {
const rows = [{ id: 1, name: 'News' }];
const pending = createDeferred<typeof rows>();
const orderBy = jest.fn(() => pending.promise);
const where = jest.fn(() => ({ orderBy }));
const from = jest.fn(() => ({ where }));
const db = {
select: jest.fn(() => ({ from })),
} as unknown as AppDatabase;
const recording = createRecordingOperationPhaseCapture();
const result = getCategories(
db,
'playlist-1',
'live',
recording.capture
);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ,
},
]);
pending.resolve(rows);
await expect(result).resolves.toBe(rows);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ,
},
{
boundary: 'end',
metadata: { itemCount: 1 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ,
},
]);
});
it('captures normalization and the existing category write separately', async () => {
const existingWhere = jest.fn().mockResolvedValue([{ count: 0 }]);
const select = jest.fn(() => ({
from: jest.fn(() => ({ where: existingWhere })),
}));
const onConflictDoNothing = jest.fn().mockResolvedValue(undefined);
const values = jest.fn(() => ({ onConflictDoNothing }));
const insert = jest.fn(() => ({ values }));
const db = { insert, select } as unknown as AppDatabase;
const recording = createRecordingOperationPhaseCapture();
await expect(
saveCategories(
db,
'playlist-1',
[
{ category_name: 'News', category_id: '101' },
{ category_name: 'Broken', category_id: 'not-a-number' },
{ category_name: 'Sports', category_id: 102 },
],
'live',
[102],
recording.capture
)
).resolves.toEqual({ success: true });
expect(values).toHaveBeenCalledWith([
expect.objectContaining({ name: 'News', xtreamId: 101 }),
expect.objectContaining({
hidden: true,
name: 'Sports',
xtreamId: 102,
}),
]);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES,
},
{
boundary: 'end',
metadata: { itemCount: 2 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES,
},
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS,
},
{
boundary: 'end',
metadata: { itemCount: 2 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS,
},
]);
});
});
@@ -1,12 +1,16 @@
import { and, eq, inArray, sql } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
type XtreamCategoryInput = {
category_name: string;
category_id: string | number;
};
type XtreamCategoryValue = typeof schema.categories.$inferInsert;
// Category rows cross the DB-worker IPC boundary in the snake_case wire
// shape declared by XCategoryFromDb/XtreamCategoryFromDb. A bare select()
// would leak Drizzle's camelCase property names (xtreamId, playlistId)
@@ -29,6 +33,49 @@ function normalizeXtreamCategoryId(
return Number.isNaN(xtreamId) ? null : xtreamId;
}
function normalizeXtreamCategories(
playlistId: string,
categories: XtreamCategoryInput[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
): XtreamCategoryValue[] {
const hiddenSet = new Set(hiddenCategoryXtreamIds || []);
return categories.flatMap((category) => {
const xtreamId = normalizeXtreamCategoryId(category.category_id);
if (xtreamId === null) {
return [];
}
return [
{
playlistId,
name: category.category_name,
type,
xtreamId,
hidden: hiddenSet.has(xtreamId),
},
];
});
}
async function insertXtreamCategories(
db: AppDatabase,
values: XtreamCategoryValue[]
): Promise<void> {
await db
.insert(schema.categories)
.values(values)
.onConflictDoNothing({
target: [
schema.categories.playlistId,
schema.categories.type,
schema.categories.xtreamId,
],
});
}
export async function hasCategories(
db: AppDatabase,
playlistId: string,
@@ -50,13 +97,14 @@ export async function hasCategories(
export async function getCategories(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movies' | 'series'
type: 'live' | 'movies' | 'series',
capturePhase?: DatabaseOperationPerformancePhaseCapture
) {
// Xtream categories are inserted once in provider order and existing
// xtream IDs are preserved, so row id order represents server order.
// If partial category re-inserts are added later, persist a provider
// sort index instead of relying on the insertion id.
return db
const query = db
.select(categoryWireShape)
.from(schema.categories)
.where(
@@ -67,6 +115,14 @@ export async function getCategories(
)
)
.orderBy(schema.categories.id);
return capturePhase
? capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ,
async () => query,
(rows) => ({ itemCount: rows.length })
)
: query;
}
export async function saveCategories(
@@ -74,7 +130,8 @@ export async function saveCategories(
playlistId: string,
categories: XtreamCategoryInput[],
type: 'live' | 'movies' | 'series',
hiddenCategoryXtreamIds?: number[]
hiddenCategoryXtreamIds?: number[],
capturePhase?: DatabaseOperationPerformancePhaseCapture
): Promise<{ success: boolean }> {
if (!categories || categories.length === 0) {
return { success: true };
@@ -94,39 +151,38 @@ export async function saveCategories(
return { success: true };
}
const hiddenSet = new Set(hiddenCategoryXtreamIds || []);
const values = categories.flatMap((category) => {
const xtreamId = normalizeXtreamCategoryId(category.category_id);
if (xtreamId === null) {
return [];
}
return [
{
playlistId,
name: category.category_name,
type,
xtreamId,
hidden: hiddenSet.has(xtreamId),
},
];
});
const values = capturePhase
? capturePhase.captureSync(
XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES,
() =>
normalizeXtreamCategories(
playlistId,
categories,
type,
hiddenCategoryXtreamIds
),
(result) => ({ itemCount: result.length })
)
: normalizeXtreamCategories(
playlistId,
categories,
type,
hiddenCategoryXtreamIds
);
if (values.length === 0) {
return { success: true };
}
await db
.insert(schema.categories)
.values(values)
.onConflictDoNothing({
target: [
schema.categories.playlistId,
schema.categories.type,
schema.categories.xtreamId,
],
});
if (capturePhase) {
await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS,
() => insertXtreamCategories(db, values),
() => ({ itemCount: values.length })
);
} else {
await insertXtreamCategories(db, values);
}
return { success: true };
}
@@ -0,0 +1,269 @@
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import {
clearXtreamImportCache,
getContent,
saveContent,
} from './content.operations';
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
function createContentDb(streamCount: number, timeline: string[]) {
const existingWhere = jest.fn().mockResolvedValue([{ count: 0 }]);
const categoriesWhere = jest
.fn()
.mockResolvedValue([{ id: 42, xtreamId: 201 }]);
const select = jest
.fn()
.mockReturnValueOnce({
from: jest.fn(() => ({
innerJoin: jest.fn(() => ({ where: existingWhere })),
})),
})
.mockReturnValueOnce({
from: jest.fn(() => ({ where: categoriesWhere })),
});
const run = jest.fn();
const values = jest.fn((_chunk: unknown[]) => ({
onConflictDoNothing: jest.fn(() => ({ run })),
}));
const insert = jest.fn(() => ({ values }));
const transactionResults: unknown[] = [];
const transaction = jest.fn((callback: (tx: unknown) => unknown) => {
timeline.push('transaction');
const result = callback({ insert });
transactionResults.push(result);
return result;
});
const streams = Array.from({ length: streamCount }, (_, index) => ({
category_id: '201',
name: `Channel ${index}`,
stream_id: String(index + 1),
}));
return {
db: { select, transaction } as unknown as AppDatabase,
streams,
transaction,
transactionResults,
values,
};
}
describe('content import performance phases', () => {
it('keeps the content read phase open until the ordered query resolves', async () => {
const rows = [{ id: 1, title: 'One' }];
let resolveRows!: (value: typeof rows) => void;
const pending = new Promise<typeof rows>((resolve) => {
resolveRows = resolve;
});
const orderBy = jest.fn(() => pending);
const where = jest.fn(() => ({ orderBy }));
const innerJoin = jest.fn(() => ({ where }));
const from = jest.fn(() => ({ innerJoin }));
const db = {
select: jest.fn(() => ({ from })),
} as unknown as AppDatabase;
const recording = createRecordingOperationPhaseCapture();
const result = getContent(db, 'playlist-1', 'live', recording.capture);
expect(recording.events).toHaveLength(1);
resolveRows(rows);
await expect(result).resolves.toBe(rows);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ,
},
{
boundary: 'end',
metadata: { itemCount: 1 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ,
},
]);
});
it('uses one aggregate write pair around every existing batch commit and progress callback', async () => {
const timeline: string[] = [];
const recording = createRecordingOperationPhaseCapture((event) =>
timeline.push(`${event.phase}:${event.boundary}`)
);
const harness = createContentDb(205, timeline);
const checkpoint = jest.fn(() => {
timeline.push('checkpoint');
});
const onProgress = jest.fn((progress: { current?: number }) => {
timeline.push(`progress:${progress.current}`);
});
await expect(
saveContent(
harness.db,
'playlist-1',
harness.streams,
'live',
{ checkpoint, onProgress },
recording.capture
)
).resolves.toEqual({ success: true, count: 205 });
expect(harness.transaction).toHaveBeenCalledTimes(3);
expect(harness.transactionResults).toEqual([
undefined,
undefined,
undefined,
]);
expect(
harness.values.mock.calls.map(([chunk]) => chunk.length)
).toEqual([100, 100, 5]);
expect(onProgress.mock.calls.map(([value]) => value.current)).toEqual([
100, 200, 205,
]);
const writeStart = timeline.indexOf(
'sqlite.content.write-transactions:start'
);
const writeEnd = timeline.indexOf(
'sqlite.content.write-transactions:end'
);
expect(writeStart).toBeGreaterThanOrEqual(0);
expect(writeEnd).toBeGreaterThan(timeline.lastIndexOf('progress:205'));
expect(
timeline
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => entry === 'transaction')
.every(({ index }) => index > writeStart && index < writeEnd)
).toBe(true);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ,
},
{
boundary: 'end',
metadata: { itemCount: 1 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ,
},
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT,
},
{
boundary: 'end',
metadata: { itemCount: 205 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT,
},
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS,
},
{
boundary: 'end',
metadata: { itemCount: 205 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS,
},
]);
});
it('closes the aggregate write phase on cancellation without changing the error', async () => {
const timeline: string[] = [];
const recording = createRecordingOperationPhaseCapture();
const harness = createContentDb(101, timeline);
const cancellation = new Error('cancelled');
cancellation.name = 'AbortError';
let checkpointCount = 0;
await expect(
saveContent(
harness.db,
'playlist-1',
harness.streams,
'live',
{
checkpoint: () => {
checkpointCount += 1;
if (checkpointCount === 2) throw cancellation;
},
},
recording.capture
)
).rejects.toBe(cancellation);
expect(harness.transaction).toHaveBeenCalledTimes(1);
expect(recording.events.at(-1)).toEqual({
boundary: 'end',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS,
});
});
it('captures cache deletion as one pair across content and category chunks', async () => {
const categoryRows = Array.from({ length: 150 }, (_, id) => ({ id }));
const contentRows = Array.from({ length: 205 }, (_, id) => ({ id }));
const select = jest
.fn()
.mockReturnValueOnce({
from: jest.fn(() => ({
where: jest.fn().mockResolvedValue(categoryRows),
})),
})
.mockReturnValueOnce({
from: jest.fn(() => ({
where: jest.fn().mockResolvedValue(contentRows),
})),
});
const run = jest.fn();
const deleteRows = jest.fn(() => ({
where: jest.fn(() => ({ run })),
}));
const transaction = jest.fn((callback: (tx: unknown) => unknown) =>
callback({ delete: deleteRows })
);
const db = { select, transaction } as unknown as AppDatabase;
const recording = createRecordingOperationPhaseCapture();
await clearXtreamImportCache(
db,
'playlist-1',
'live',
recording.capture
);
expect(transaction).toHaveBeenCalledTimes(5);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
},
{
boundary: 'end',
metadata: { itemCount: 355 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
},
]);
});
it('emits one zero-count cache-clear pair when no categories exist', async () => {
const where = jest.fn().mockResolvedValue([]);
const from = jest.fn(() => ({ where }));
const select = jest.fn(() => ({ from }));
const transaction = jest.fn();
const db = { select, transaction } as unknown as AppDatabase;
const recording = createRecordingOperationPhaseCapture();
await expect(
clearXtreamImportCache(db, 'playlist-1', 'live', recording.capture)
).resolves.toEqual({ success: true });
expect(transaction).not.toHaveBeenCalled();
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
},
{
boundary: 'end',
metadata: { itemCount: 0 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
},
]);
});
});
@@ -0,0 +1,124 @@
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import { searchContent } from './content.operations';
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
function createSearchDb(query: Promise<unknown[]>) {
const limit = jest.fn(() => query);
const where = jest.fn(() => ({ limit }));
const innerJoin = jest.fn(() => ({ where }));
const from = jest.fn(() => ({ innerJoin }));
const select = jest.fn(() => ({ from }));
return {
db: { select } as unknown as AppDatabase,
select,
};
}
describe('in-playlist search performance phases', () => {
it('closes query before measuring synchronous filtering and ranking', async () => {
const candidates = [
{
id: 1,
title: 'News One',
xtream_id: 101,
type: 'live',
},
{
id: 2,
title: 'Movies',
xtream_id: 102,
type: 'live',
},
];
let resolveQuery!: (value: typeof candidates) => void;
const query = new Promise<typeof candidates>((resolve) => {
resolveQuery = resolve;
});
const { db } = createSearchDb(query);
const recording = createRecordingOperationPhaseCapture();
const result = searchContent(
db,
'playlist-1',
'news',
['live'],
false,
recording.capture
);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY,
},
]);
resolveQuery(candidates);
await expect(result).resolves.toEqual([candidates[0]]);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY,
},
{
boundary: 'end',
metadata: { itemCount: 2 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY,
},
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK,
},
{
boundary: 'end',
metadata: { itemCount: 1 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK,
},
]);
});
it('closes a rejected query without starting rank and preserves identity', async () => {
const queryError = new Error('search query failed');
const { db } = createSearchDb(Promise.reject(queryError));
const recording = createRecordingOperationPhaseCapture();
await expect(
searchContent(
db,
'playlist-1',
'news',
['live'],
false,
recording.capture
)
).rejects.toBe(queryError);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY,
},
{
boundary: 'end',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY,
},
]);
});
it('keeps invalid input as zero-work with no query or phases', async () => {
const { db, select } = createSearchDb(Promise.resolve([]));
const recording = createRecordingOperationPhaseCapture();
await expect(
searchContent(
db,
'playlist-1',
'',
['live'],
false,
recording.capture
)
).resolves.toEqual([]);
expect(select).not.toHaveBeenCalled();
expect(recording.events).toEqual([]);
});
});
@@ -9,6 +9,7 @@ import {
GlobalSearchResult,
GlobalSearchResultSource,
M3uGlobalSearchResult,
XTREAM_DATABASE_PERFORMANCE_PHASE,
getXtreamRecentlyAddedMaxEpochSeconds,
toXtreamRecentlyAddedEpochSeconds,
XtreamGlobalSearchResult,
@@ -31,12 +32,15 @@ import {
shouldUseContentTitleFts,
shouldUseContentTitlePrefixIndex,
} from './content-search.util';
import type { OperationControl } from './operation-control';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
import {
checkpointOperation,
chunkValues,
type OperationControl,
reportOperationProgress,
} from './operation-control';
deleteXtreamCacheRows,
normalizeXtreamContentValues,
rankSearchCandidates,
writeXtreamContentValues,
type XtreamContentValue,
} from './xtream-content-operation-steps';
export { scoreSearchTextMatch } from './content-search.util';
@@ -673,7 +677,8 @@ export async function hasContent(
export async function getContent(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movie' | 'series'
type: 'live' | 'movie' | 'series',
capturePhase?: DatabaseOperationPerformancePhaseCapture
) {
const baseQuery = db
.select(selectContentFields())
@@ -689,17 +694,22 @@ export async function getContent(
)
);
return type === 'live'
? baseQuery.orderBy(asc(schema.content.id))
: baseQuery.orderBy(desc(schema.content.added));
const query =
type === 'live'
? baseQuery.orderBy(asc(schema.content.id))
: baseQuery.orderBy(desc(schema.content.added));
return capturePhase
? capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ,
async () => query,
(rows) => ({ itemCount: rows.length })
)
: query;
}
export type RecentlyAddedPlaylistType =
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url';
'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
export async function getGlobalRecentlyAdded(
db: AppDatabase,
@@ -796,20 +806,6 @@ function getGlobalRecentlyAddedByType(
.limit(limit);
}
type XtreamContentValue = {
categoryId: number;
title: string;
rating: string;
added: string;
posterUrl: string;
epgChannelId?: string | null;
tvArchive?: number | null;
tvArchiveDuration?: number | null;
directSource?: string | null;
xtreamId: number;
type: 'live' | 'movie' | 'series';
};
type XtreamContentSource = Record<string, unknown> & {
category_id?: string | number;
rating?: string | number;
@@ -898,7 +894,8 @@ export async function saveContent(
playlistId: string,
streams: Array<Record<string, unknown>>,
type: 'live' | 'movie' | 'series',
control?: OperationControl
control?: OperationControl,
capturePhase?: DatabaseOperationPerformancePhaseCapture
): Promise<{ success: boolean; count: number }> {
const dbType =
type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live';
@@ -922,7 +919,7 @@ export async function saveContent(
return { success: true, count: existingContent[0].count };
}
const categories = await db
const categoriesQuery = db
.select({
id: schema.categories.id,
xtreamId: schema.categories.xtreamId,
@@ -935,49 +932,46 @@ export async function saveContent(
)
);
const categoryMap = new Map(
categories.map((category) => [category.xtreamId, category.id])
);
const categories = capturePhase
? await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ,
async () => categoriesQuery,
(rows) => ({ itemCount: rows.length })
)
: await categoriesQuery;
const values = capturePhase
? capturePhase.captureSync(
XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT,
() =>
normalizeXtreamContentValues(
streams,
type,
categories,
toXtreamContentValue
),
(result) => ({ itemCount: result.length })
)
: normalizeXtreamContentValues(
streams,
type,
categories,
toXtreamContentValue
);
const values = streams
.map((stream) => toXtreamContentValue(stream, type, categoryMap))
.filter((value): value is XtreamContentValue => value !== null);
const total = values.length;
const chunkSize = 100;
let totalInserted = 0;
for (let index = 0; index < values.length; index += chunkSize) {
await checkpointOperation(control);
const chunk = values.slice(index, index + chunkSize);
await db.transaction((tx) => {
tx.insert(schema.content)
.values(chunk)
.onConflictDoNothing({
target: [
schema.content.categoryId,
schema.content.type,
schema.content.xtreamId,
],
})
.run();
});
totalInserted += chunk.length;
await reportOperationProgress(control, {
phase: 'saving-content',
current: totalInserted,
total,
increment: chunk.length,
});
}
return { success: true, count: totalInserted };
return capturePhase
? capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS,
() => writeXtreamContentValues(db, values, control),
(result) => ({ itemCount: result.count })
)
: writeXtreamContentValues(db, values, control);
}
export async function clearXtreamImportCache(
db: AppDatabase,
playlistId: string,
type: 'live' | 'movie' | 'series'
type: 'live' | 'movie' | 'series',
capturePhase?: DatabaseOperationPerformancePhaseCapture
): Promise<{ success: boolean }> {
const dbType =
type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live';
@@ -994,7 +988,13 @@ export async function clearXtreamImportCache(
const categoryIds = categoryRows.map((category) => category.id);
if (categoryIds.length === 0) {
return { success: true };
return capturePhase
? capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
async () => ({ success: true }),
() => ({ itemCount: 0 })
)
: { success: true };
}
const contentRows = await db
@@ -1002,26 +1002,13 @@ export async function clearXtreamImportCache(
.from(schema.content)
.where(inArray(schema.content.categoryId, categoryIds));
for (const chunk of chunkValues(
contentRows.map((row) => row.id),
100
)) {
await db.transaction((tx) => {
tx.delete(schema.content)
.where(inArray(schema.content.id, chunk))
.run();
});
}
for (const chunk of chunkValues(categoryIds, 100)) {
await db.transaction((tx) => {
tx.delete(schema.categories)
.where(inArray(schema.categories.id, chunk))
.run();
});
}
return { success: true };
return capturePhase
? capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS,
() => deleteXtreamCacheRows(db, contentRows, categoryIds),
() => ({ itemCount: contentRows.length + categoryIds.length })
)
: deleteXtreamCacheRows(db, contentRows, categoryIds);
}
export async function getContentByXtreamId(
@@ -1057,7 +1044,8 @@ export async function searchContent(
playlistId: string,
searchTerm: string,
types: string[],
excludeHidden = false
excludeHidden = false,
capturePhase?: DatabaseOperationPerformancePhaseCapture
) {
if (!types || types.length === 0 || !normalizeSearchMatchText(searchTerm)) {
return [];
@@ -1076,7 +1064,7 @@ export async function searchContent(
conditions.push(eq(schema.categories.hidden, false));
}
const candidates = await db
const query = db
.select(selectContentFields())
.from(schema.content)
.innerJoin(
@@ -1086,12 +1074,21 @@ export async function searchContent(
.where(and(...conditions))
.limit(200);
return candidates
.filter(
(item) =>
scoreSearchTextMatch(item.title ?? '', searchTerm) !== null
)
.slice(0, 50);
const candidates = capturePhase
? await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY,
async () => query,
(rows) => ({ itemCount: rows.length })
)
: await query;
return capturePhase
? capturePhase.captureSync(
XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK,
() => rankSearchCandidates(candidates, searchTerm),
(result) => ({ itemCount: result.length })
)
: rankSearchCandidates(candidates, searchTerm);
}
export async function globalSearch(
@@ -0,0 +1,68 @@
import type {
XtreamDatabasePerformancePhase,
PerformancePhaseMetadata,
} from '@iptvnator/shared/interfaces';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
export interface RecordedOperationPhase {
readonly boundary: 'end' | 'start';
readonly metadata?: PerformancePhaseMetadata;
readonly phase: XtreamDatabasePerformancePhase;
}
export function createRecordingOperationPhaseCapture(
onBoundary?: (event: RecordedOperationPhase) => void
): {
readonly capture: DatabaseOperationPerformancePhaseCapture;
readonly events: RecordedOperationPhase[];
} {
const events: RecordedOperationPhase[] = [];
const record = (event: RecordedOperationPhase): void => {
events.push(event);
onBoundary?.(event);
};
return {
capture: {
async captureAsync<TResult>(
phase: XtreamDatabasePerformancePhase,
execute: () => Promise<TResult>,
metadata?: (result: TResult) => PerformancePhaseMetadata
): Promise<TResult> {
record({ boundary: 'start', phase });
try {
const result = await execute();
record({
boundary: 'end',
metadata: metadata?.(result),
phase,
});
return result;
} catch (error) {
record({ boundary: 'end', phase });
throw error;
}
},
captureSync<TResult>(
phase: XtreamDatabasePerformancePhase,
execute: () => TResult,
metadata?: (result: TResult) => PerformancePhaseMetadata
): TResult {
record({ boundary: 'start', phase });
try {
const result = execute();
record({
boundary: 'end',
metadata: metadata?.(result),
phase,
});
return result;
} catch (error) {
record({ boundary: 'end', phase });
throw error;
}
},
},
events,
};
}
@@ -0,0 +1,17 @@
import type {
PerformancePhaseMetadata,
XtreamDatabasePerformancePhase,
} from '@iptvnator/shared/interfaces';
export interface DatabaseOperationPerformancePhaseCapture {
captureAsync: <TResult>(
phase: XtreamDatabasePerformancePhase,
execute: () => Promise<TResult>,
metadata?: (result: TResult) => PerformancePhaseMetadata
) => Promise<TResult>;
captureSync: <TResult>(
phase: XtreamDatabasePerformancePhase,
execute: () => TResult,
metadata?: (result: TResult) => PerformancePhaseMetadata
) => TResult;
}
@@ -0,0 +1,165 @@
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
import { deletePlaylist } from './playlist.operations';
function createSelectStep(
rows: readonly unknown[],
label: string,
timeline: string[]
) {
const where = jest.fn(() => {
timeline.push(label);
return Promise.resolve(rows);
});
const from = jest.fn(() => ({ where }));
return { from };
}
function createDeleteHarness() {
const timeline: string[] = [];
const selections = [
['select:favorites', [{ id: 1 }, { id: 2 }]],
['select:recently-viewed', [{ id: 3 }]],
['select:playback-positions', []],
['select:downloads', [{ id: 4 }]],
['select:categories', [{ id: 10 }, { id: 11 }]],
[
'select:content',
Array.from({ length: 205 }, (_, id) => ({ id: id + 100 })),
],
] as const;
const select = jest.fn();
for (const [label, rows] of selections) {
select.mockReturnValueOnce(createSelectStep(rows, label, timeline));
}
const transactionRun = jest.fn();
const transactionDelete = jest.fn(() => ({
where: jest.fn(() => ({ run: transactionRun })),
}));
const transaction = jest.fn((execute: (tx: unknown) => unknown) => {
timeline.push('transaction');
return execute({ delete: transactionDelete });
});
const playlistWhere = jest.fn(() => {
timeline.push('delete:playlist');
return Promise.resolve(undefined);
});
const deletePlaylistRow = jest.fn(() => ({ where: playlistWhere }));
return {
db: {
delete: deletePlaylistRow,
select,
transaction,
} as unknown as AppDatabase,
timeline,
transaction,
};
}
describe('playlist delete performance phases', () => {
it('counts collected dependent IDs and includes the final playlist row in writes', async () => {
const harness = createDeleteHarness();
const recording = createRecordingOperationPhaseCapture((event) => {
harness.timeline.push(`${event.phase}:${event.boundary}`);
});
const checkpoint = jest.fn(() => {
harness.timeline.push('checkpoint');
});
const onProgress = jest.fn((progress: { phase: string }) => {
harness.timeline.push(`progress:${progress.phase}`);
});
await expect(
deletePlaylist(
harness.db,
'playlist-1',
{ checkpoint, onProgress },
recording.capture
)
).resolves.toEqual({ success: true });
expect(harness.transaction).toHaveBeenCalledTimes(7);
// Seven committed chunks plus the final playlist row each keep both
// the pre-write and post-progress cooperative checkpoints.
expect(checkpoint).toHaveBeenCalledTimes(16);
expect(harness.timeline.slice(1, 7)).toEqual([
'select:favorites',
'select:recently-viewed',
'select:playback-positions',
'select:downloads',
'select:categories',
'select:content',
]);
const writeStart = harness.timeline.indexOf(
'sqlite.playlist-delete.write-transactions:start'
);
const writeEnd = harness.timeline.indexOf(
'sqlite.playlist-delete.write-transactions:end'
);
expect(harness.timeline.indexOf('delete:playlist')).toBeGreaterThan(
writeStart
);
expect(writeEnd).toBeGreaterThan(
harness.timeline.indexOf('progress:deleting-playlist')
);
expect(
harness.timeline
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => entry === 'transaction')
.every(({ index }) => index > writeStart && index < writeEnd)
).toBe(true);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS,
},
{
boundary: 'end',
metadata: { itemCount: 211 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS,
},
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS,
},
{
boundary: 'end',
metadata: { itemCount: 212 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS,
},
]);
});
it('closes the write phase on cancellation before deleting the playlist row', async () => {
const harness = createDeleteHarness();
const recording = createRecordingOperationPhaseCapture();
const cancellation = new Error('synthetic cancellation');
cancellation.name = 'AbortError';
let checkpointCount = 0;
await expect(
deletePlaylist(
harness.db,
'playlist-1',
{
checkpoint: () => {
checkpointCount += 1;
if (checkpointCount === 2) {
throw cancellation;
}
},
},
recording.capture
)
).rejects.toBe(cancellation);
expect(harness.transaction).toHaveBeenCalledTimes(1);
expect(harness.timeline).not.toContain('delete:playlist');
expect(recording.events.at(-1)).toEqual({
boundary: 'end',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS,
});
});
});
@@ -4,6 +4,7 @@ import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces';
import {
APP_PLAYLIST_GET_PERFORMANCE_PHASE,
APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE,
XTREAM_DATABASE_PERFORMANCE_PHASE,
type AppPlaylistGetPerformancePhase,
type AppPlaylistUpsertPerformancePhase,
type PerformancePhaseMetadata,
@@ -15,6 +16,7 @@ import {
type OperationControl,
reportOperationProgress,
} from './operation-control';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
const PLAYLIST_TYPES = {
XTREAM: 'xtream',
@@ -365,13 +367,10 @@ export async function upsertAppPlaylist(
}
const write = async () => {
await db
.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
await db.insert(schema.playlists).values(row).onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
};
if (capturePhase) {
await capturePhase.captureAsync(
@@ -483,17 +482,14 @@ export async function getAppPlaylist(
(value) => ({ itemCount: value.length })
)
: await read();
const deserialize = () =>
rows[0] ? parseAppPlaylist(rows[0]) : null;
const deserialize = () => (rows[0] ? parseAppPlaylist(rows[0]) : null);
return capturePhase
? capturePhase.captureSync(
APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST,
deserialize,
(value) => ({
itemCount: value
? (getPlaylistItemCount(value) ?? 0)
: 0,
itemCount: value ? (getPlaylistItemCount(value) ?? 0) : 0,
})
)
: deserialize();
@@ -592,11 +588,19 @@ export async function updatePlaylist(
return { success: true };
}
export async function deletePlaylist(
interface PlaylistDeletionCollection {
readonly categoryIds: number[];
readonly contentRows: Array<{ id: number }>;
readonly downloadRows: Array<{ id: number }>;
readonly favoriteRows: Array<{ id: number }>;
readonly playbackPositionRows: Array<{ id: number }>;
readonly recentlyViewedRows: Array<{ id: number }>;
}
async function collectPlaylistDeletionRows(
db: AppDatabase,
playlistId: string,
control?: OperationControl
): Promise<{ success: boolean }> {
playlistId: string
): Promise<PlaylistDeletionCollection> {
const [
favoriteRows,
recentlyViewedRows,
@@ -634,40 +638,69 @@ export async function deletePlaylist(
.where(inArray(schema.content.categoryId, categoryIds))
: [];
return {
categoryIds,
contentRows,
downloadRows,
favoriteRows,
playbackPositionRows,
recentlyViewedRows,
};
}
function countPlaylistDeletionRows(
collection: PlaylistDeletionCollection
): number {
return (
collection.favoriteRows.length +
collection.recentlyViewedRows.length +
collection.playbackPositionRows.length +
collection.downloadRows.length +
collection.contentRows.length +
collection.categoryIds.length
);
}
async function deleteCollectedPlaylistRows(
db: AppDatabase,
playlistId: string,
collection: PlaylistDeletionCollection,
control?: OperationControl
): Promise<number> {
for (const [phase, ids, column, table] of [
[
'deleting-favorites',
favoriteRows.map((row) => row.id),
collection.favoriteRows.map((row) => row.id),
schema.favorites.id,
schema.favorites,
],
[
'deleting-recently-viewed',
recentlyViewedRows.map((row) => row.id),
collection.recentlyViewedRows.map((row) => row.id),
schema.recentlyViewed.id,
schema.recentlyViewed,
],
[
'deleting-playback-positions',
playbackPositionRows.map((row) => row.id),
collection.playbackPositionRows.map((row) => row.id),
schema.playbackPositions.id,
schema.playbackPositions,
],
[
'deleting-downloads',
downloadRows.map((row) => row.id),
collection.downloadRows.map((row) => row.id),
schema.downloads.id,
schema.downloads,
],
[
'deleting-content',
contentRows.map((row) => row.id),
collection.contentRows.map((row) => row.id),
schema.content.id,
schema.content,
],
[
'deleting-categories',
categoryIds,
collection.categoryIds,
schema.categories.id,
schema.categories,
],
@@ -701,6 +734,41 @@ export async function deletePlaylist(
increment: 1,
});
return countPlaylistDeletionRows(collection) + 1;
}
export async function deletePlaylist(
db: AppDatabase,
playlistId: string,
control?: OperationControl,
capturePhase?: DatabaseOperationPerformancePhaseCapture
): Promise<{ success: boolean }> {
const collection = capturePhase
? await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS,
() => collectPlaylistDeletionRows(db, playlistId),
(result) => ({
itemCount: countPlaylistDeletionRows(result),
})
)
: await collectPlaylistDeletionRows(db, playlistId);
if (capturePhase) {
await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS,
() =>
deleteCollectedPlaylistRows(
db,
playlistId,
collection,
control
),
(itemCount) => ({ itemCount })
);
} else {
await deleteCollectedPlaylistRows(db, playlistId, collection, control);
}
return { success: true };
}
@@ -0,0 +1,117 @@
import { inArray } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import { scoreSearchTextMatch } from './content-search.util';
import {
checkpointOperation,
chunkValues,
type OperationControl,
reportOperationProgress,
} from './operation-control';
export type XtreamContentValue = {
categoryId: number;
title: string;
rating: string;
added: string;
posterUrl: string;
epgChannelId?: string | null;
tvArchive?: number | null;
tvArchiveDuration?: number | null;
directSource?: string | null;
xtreamId: number;
type: 'live' | 'movie' | 'series';
};
export function normalizeXtreamContentValues(
streams: Array<Record<string, unknown>>,
type: 'live' | 'movie' | 'series',
categories: Array<{ id: number; xtreamId: number }>,
normalizeValue: (
stream: Record<string, unknown>,
type: 'live' | 'movie' | 'series',
categoryMap: Map<number, number>
) => XtreamContentValue | null
): XtreamContentValue[] {
const categoryMap = new Map(
categories.map((category) => [category.xtreamId, category.id])
);
return streams
.map((stream) => normalizeValue(stream, type, categoryMap))
.filter((value): value is XtreamContentValue => value !== null);
}
export async function writeXtreamContentValues(
db: AppDatabase,
values: XtreamContentValue[],
control?: OperationControl
): Promise<{ success: boolean; count: number }> {
const total = values.length;
const chunkSize = 100;
let totalInserted = 0;
for (let index = 0; index < values.length; index += chunkSize) {
await checkpointOperation(control);
const chunk = values.slice(index, index + chunkSize);
await db.transaction((tx) => {
tx.insert(schema.content)
.values(chunk)
.onConflictDoNothing({
target: [
schema.content.categoryId,
schema.content.type,
schema.content.xtreamId,
],
})
.run();
});
totalInserted += chunk.length;
await reportOperationProgress(control, {
phase: 'saving-content',
current: totalInserted,
total,
increment: chunk.length,
});
}
return { success: true, count: totalInserted };
}
export async function deleteXtreamCacheRows(
db: AppDatabase,
contentRows: Array<{ id: number }>,
categoryIds: number[]
): Promise<{ success: boolean }> {
for (const chunk of chunkValues(
contentRows.map((row) => row.id),
100
)) {
await db.transaction((tx) => {
tx.delete(schema.content)
.where(inArray(schema.content.id, chunk))
.run();
});
}
for (const chunk of chunkValues(categoryIds, 100)) {
await db.transaction((tx) => {
tx.delete(schema.categories)
.where(inArray(schema.categories.id, chunk))
.run();
});
}
return { success: true };
}
export function rankSearchCandidates<
TCandidate extends { title: string | null },
>(candidates: TCandidate[], searchTerm: string): TCandidate[] {
return candidates
.filter(
(item) =>
scoreSearchTextMatch(item.title ?? '', searchTerm) !== null
)
.slice(0, 50);
}
@@ -0,0 +1,217 @@
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers';
import { deleteXtreamContent } from './xtream.operations';
function createSelectStep(
rows: readonly unknown[],
label: string,
timeline: string[]
) {
const where = jest.fn(() => {
timeline.push(label);
return Promise.resolve(rows);
});
const innerJoin = jest.fn(() => ({ where }));
const from = jest.fn(() => ({ innerJoin, where }));
return { from };
}
function createDeleteHarness() {
const timeline: string[] = [];
const categories = [
{ hidden: true, id: 11, type: 'live', xtreamId: 101 },
{ hidden: false, id: 12, type: 'movies', xtreamId: 102 },
];
const favorites = [
{
addedAt: '2026-01-01T00:00:00.000Z',
contentType: 'live',
position: 3,
xtreamId: 201,
},
];
const recentlyViewed = [
{
contentType: 'movie',
viewedAt: '2026-01-02T00:00:00.000Z',
xtreamId: 202,
},
];
const content = Array.from({ length: 205 }, (_, id) => ({ id: id + 1 }));
const select = jest
.fn()
.mockReturnValueOnce(
createSelectStep(categories, 'select:categories', timeline)
)
.mockReturnValueOnce(
createSelectStep(favorites, 'select:favorites', timeline)
)
.mockReturnValueOnce(
createSelectStep(recentlyViewed, 'select:recently-viewed', timeline)
)
.mockReturnValueOnce(
createSelectStep(content, 'select:content', timeline)
);
const run = jest.fn();
const deleteRows = jest.fn(() => ({
where: jest.fn(() => ({ run })),
}));
const transaction = jest.fn((execute: (tx: unknown) => unknown) => {
timeline.push('transaction');
return execute({ delete: deleteRows });
});
return {
content,
db: { select, transaction } as unknown as AppDatabase,
timeline,
transaction,
};
}
describe('Xtream delete performance phases', () => {
it('times all user-data collection but counts only content/category deletion candidates', async () => {
const harness = createDeleteHarness();
const recording = createRecordingOperationPhaseCapture((event) => {
harness.timeline.push(`${event.phase}:${event.boundary}`);
});
const checkpoint = jest.fn(() => {
harness.timeline.push('checkpoint');
});
const onProgress = jest.fn((progress: { current?: number }) => {
harness.timeline.push(`progress:${progress.current}`);
});
await expect(
deleteXtreamContent(
harness.db,
'playlist-1',
{ checkpoint, onProgress },
recording.capture
)
).resolves.toEqual({
favorites: [
{
addedAt: '2026-01-01T00:00:00.000Z',
contentType: 'live',
position: 3,
xtreamId: 201,
},
],
hiddenCategories: [{ categoryType: 'live', xtreamId: 101 }],
recentlyViewed: [
{
contentType: 'movie',
viewedAt: '2026-01-02T00:00:00.000Z',
xtreamId: 202,
},
],
success: true,
});
expect(harness.transaction).toHaveBeenCalledTimes(4);
// Existing progress reporting performs a second cooperative
// checkpoint after each committed batch.
expect(checkpoint).toHaveBeenCalledTimes(8);
expect(onProgress.mock.calls.map(([value]) => value.current)).toEqual([
100, 200, 205, 2,
]);
expect(harness.timeline.slice(1, 5)).toEqual([
'select:categories',
'select:favorites',
'select:recently-viewed',
'select:content',
]);
const writeStart = harness.timeline.indexOf(
'sqlite.xtream-delete.write-transactions:start'
);
const writeEnd = harness.timeline.indexOf(
'sqlite.xtream-delete.write-transactions:end'
);
expect(writeEnd).toBeGreaterThan(
harness.timeline.lastIndexOf('progress:2')
);
expect(
harness.timeline
.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => entry === 'transaction')
.every(({ index }) => index > writeStart && index < writeEnd)
).toBe(true);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA,
},
{
boundary: 'end',
metadata: { itemCount: 207 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA,
},
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS,
},
{
boundary: 'end',
metadata: { itemCount: 207 },
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS,
},
]);
});
it('closes the write phase on cancellation and preserves error identity', async () => {
const harness = createDeleteHarness();
const recording = createRecordingOperationPhaseCapture();
const cancellation = new Error('synthetic cancellation');
cancellation.name = 'AbortError';
let checkpointCount = 0;
await expect(
deleteXtreamContent(
harness.db,
'playlist-1',
{
checkpoint: () => {
checkpointCount += 1;
if (checkpointCount === 2) {
throw cancellation;
}
},
},
recording.capture
)
).rejects.toBe(cancellation);
expect(harness.transaction).toHaveBeenCalledTimes(1);
expect(recording.events.at(-1)).toEqual({
boundary: 'end',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS,
});
});
it('closes collection on query failure and preserves error identity', async () => {
const failure = new Error('synthetic select failure');
const where = jest.fn().mockRejectedValue(failure);
const from = jest.fn(() => ({ where }));
const db = {
select: jest.fn(() => ({ from })),
} as unknown as AppDatabase;
const recording = createRecordingOperationPhaseCapture();
await expect(
deleteXtreamContent(db, 'playlist-1', undefined, recording.capture)
).rejects.toBe(failure);
expect(recording.events).toEqual([
{
boundary: 'start',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA,
},
{
boundary: 'end',
phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA,
},
]);
});
});
@@ -1,9 +1,10 @@
import { and, eq, inArray } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type {
XtreamBackupFavoriteItem,
XtreamBackupHiddenCategory,
XtreamBackupRecentlyViewedItem,
import {
XTREAM_DATABASE_PERFORMANCE_PHASE,
type XtreamBackupFavoriteItem,
type XtreamBackupHiddenCategory,
type XtreamBackupRecentlyViewedItem,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import {
@@ -12,6 +13,7 @@ import {
type OperationControl,
reportOperationProgress,
} from './operation-control';
import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture';
const DEFAULT_BATCH_SIZE = 100;
@@ -28,16 +30,18 @@ function toContentIdentityKey(
return `${contentType}:${xtreamId}`;
}
export async function deleteXtreamContent(
interface XtreamDeletionCollection {
readonly categoryIds: number[];
readonly contentRows: Array<{ id: number }>;
readonly favorites: XtreamBackupFavoriteItem[];
readonly hiddenCategories: XtreamBackupHiddenCategory[];
readonly recentlyViewed: XtreamBackupRecentlyViewedItem[];
}
async function collectXtreamDeletionRows(
db: AppDatabase,
playlistId: string,
control?: OperationControl
): Promise<{
success: boolean;
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
hiddenCategories: XtreamBackupHiddenCategory[];
}> {
playlistId: string
): Promise<XtreamDeletionCollection> {
const categories = await db
.select({
id: schema.categories.id,
@@ -58,6 +62,7 @@ export async function deleteXtreamContent(
let favorites: XtreamBackupFavoriteItem[] = [];
let recentlyViewed: XtreamBackupRecentlyViewedItem[] = [];
let contentRows: Array<{ id: number }> = [];
if (categoryIds.length > 0) {
const favoritedContent = await db
@@ -110,43 +115,58 @@ export async function deleteXtreamContent(
viewedAt: item.viewedAt || new Date().toISOString(),
}));
const contentRows = await db
contentRows = await db
.select({ id: schema.content.id })
.from(schema.content)
.where(inArray(schema.content.categoryId, categoryIds));
}
let deletedContent = 0;
const totalContent = contentRows.length;
return {
categoryIds,
contentRows,
favorites,
hiddenCategories,
recentlyViewed,
};
}
for (const chunk of chunkValues(
contentRows.map((content) => content.id),
DEFAULT_BATCH_SIZE
)) {
await checkpointOperation(control);
await db.transaction((tx) => {
tx
.delete(schema.content)
.where(inArray(schema.content.id, chunk))
.run();
});
deletedContent += chunk.length;
await reportOperationProgress(control, {
phase: 'deleting-content',
current: deletedContent,
total: totalContent,
increment: chunk.length,
});
}
async function deleteCollectedXtreamRows(
db: AppDatabase,
collection: XtreamDeletionCollection,
control?: OperationControl
): Promise<number> {
let deletedContent = 0;
const totalContent = collection.contentRows.length;
for (const chunk of chunkValues(
collection.contentRows.map((content) => content.id),
DEFAULT_BATCH_SIZE
)) {
await checkpointOperation(control);
await db.transaction((tx) => {
tx.delete(schema.content)
.where(inArray(schema.content.id, chunk))
.run();
});
deletedContent += chunk.length;
await reportOperationProgress(control, {
phase: 'deleting-content',
current: deletedContent,
total: totalContent,
increment: chunk.length,
});
}
let deletedCategories = 0;
const totalCategories = categoryIds.length;
const totalCategories = collection.categoryIds.length;
for (const chunk of chunkValues(categoryIds, DEFAULT_BATCH_SIZE)) {
for (const chunk of chunkValues(
collection.categoryIds,
DEFAULT_BATCH_SIZE
)) {
await checkpointOperation(control);
await db.transaction((tx) => {
tx
.delete(schema.categories)
tx.delete(schema.categories)
.where(inArray(schema.categories.id, chunk))
.run();
});
@@ -159,11 +179,46 @@ export async function deleteXtreamContent(
});
}
return totalContent + totalCategories;
}
export async function deleteXtreamContent(
db: AppDatabase,
playlistId: string,
control?: OperationControl,
capturePhase?: DatabaseOperationPerformancePhaseCapture
): Promise<{
success: boolean;
favorites: XtreamBackupFavoriteItem[];
recentlyViewed: XtreamBackupRecentlyViewedItem[];
hiddenCategories: XtreamBackupHiddenCategory[];
}> {
const collection = capturePhase
? await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA,
() => collectXtreamDeletionRows(db, playlistId),
(result) => ({
itemCount:
result.contentRows.length + result.categoryIds.length,
})
)
: await collectXtreamDeletionRows(db, playlistId);
if (capturePhase) {
await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS,
() => deleteCollectedXtreamRows(db, collection, control),
(itemCount) => ({ itemCount })
);
} else {
await deleteCollectedXtreamRows(db, collection, control);
}
return {
success: true,
favorites,
recentlyViewed,
hiddenCategories,
favorites: collection.favorites,
recentlyViewed: collection.recentlyViewed,
hiddenCategories: collection.hiddenCategories,
};
}
@@ -0,0 +1,263 @@
import type {
AxiosResponseHeaders,
AxiosResponseTransformer,
InternalAxiosRequestConfig,
} from 'axios';
import {
XTREAM_MAIN_PERFORMANCE_PHASE,
type PerformancePhaseEvent,
type XtreamMainPerformancePhase,
} from '@iptvnator/shared/interfaces';
import {
createXtreamMainPerformanceCapture,
createXtreamMeasuredTransformResponse,
type XtreamMainPerformanceCapture,
type XtreamMainPerformanceCaptureRuntime,
} from './xtream-performance';
type MainPhaseEvent = PerformancePhaseEvent<XtreamMainPerformancePhase>;
function requireCapture(
runtime: XtreamMainPerformanceCaptureRuntime,
events: MainPhaseEvent[],
requestId?: string
): XtreamMainPerformanceCapture {
const capture = createXtreamMainPerformanceCapture({
emit: (event) => events.push(event),
enabled: true,
requestId,
runtime,
});
if (!capture) {
throw new Error('Expected performance capture');
}
return capture;
}
describe('Xtream main performance runtime hardening', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('uses a clock-free safe identifier and epoch when every injected clock throws', () => {
const events: MainPhaseEvent[] = [];
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => {
throw new Error('identifier failed');
}),
readEpochMs: jest.fn(() => {
throw new Error('epoch failed');
}),
readMonotonicMs: jest.fn(() => {
throw new Error('monotonic failed');
}),
};
const wallClock = jest.spyOn(Date, 'now').mockImplementation(() => {
throw new Error('wall clock must stay untouched');
});
let capture: XtreamMainPerformanceCapture | undefined;
expect(() => {
capture = requireCapture(runtime, events, ' unsafe request id ');
}).not.toThrow();
expect(() =>
capture?.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
() => undefined
)
).not.toThrow();
expect(wallClock).not.toHaveBeenCalled();
expect(events).toHaveLength(2);
expect(
events.every(
({ epochMs, requestId }) =>
Number.isFinite(epochMs) &&
epochMs > 0 &&
/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(requestId)
)
).toBe(true);
});
it('keeps emitted epochs positive, finite, and nondecreasing across invalid and backward samples', () => {
const events: MainPhaseEvent[] = [];
const epochSamples = [Number.NaN, 100, 90, Number.POSITIVE_INFINITY];
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => 'unused'),
readEpochMs: jest.fn(() => epochSamples.shift() ?? 80),
readMonotonicMs: jest.fn(() => 10),
};
const capture = requireCapture(runtime, events, 'xtream-mabc1001-1');
capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
() => undefined
);
capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
() => undefined
);
const epochs = events.map(({ epochMs }) => epochMs);
expect(epochs.every((epochMs) => Number.isFinite(epochMs))).toBe(true);
expect(epochs.every((epochMs) => epochMs > 0)).toBe(true);
expect(
epochs.every(
(epochMs, index) => index === 0 || epochMs >= epochs[index - 1]
)
).toBe(true);
});
it('matches Axios by visiting a sparse transform entry and closing the failed JSON phase', () => {
const events: MainPhaseEvent[] = [];
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => 'unused'),
readEpochMs: jest.fn(() => 100),
readMonotonicMs: jest.fn(() => 10),
};
const capture = requireCapture(runtime, events, 'xtream-mabc1002-2');
const sparseTransforms = new Array<AxiosResponseTransformer>(1);
const measured = createXtreamMeasuredTransformResponse(
capture,
sparseTransforms
);
expect(() =>
measured.call(
{} as InternalAxiosRequestConfig,
'raw',
{} as AxiosResponseHeaders,
200
)
).toThrow(TypeError);
expect(events.map(({ boundary }) => boundary)).toEqual([
'start',
'end',
]);
});
it('is fail-neutral and never publishes unsafe request identifiers or business data', () => {
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => 'xtream-main-safe-fallback'),
readEpochMs: jest.fn(() => 100),
readMonotonicMs: jest.fn(() => 10),
};
const capture = createXtreamMainPerformanceCapture({
emit: () => {
throw new Error('diagnostics sink failed');
},
enabled: true,
requestId: 'provider-password-secret',
runtime,
});
const businessError = new Error('provider-password-secret');
expect(() =>
capture?.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
() => {
throw businessError;
}
)
).toThrow(businessError);
expect(runtime.createRequestId).toHaveBeenCalledTimes(1);
const events: MainPhaseEvent[] = [];
const safeCapture = requireCapture(
runtime,
events,
'provider-password-secret'
);
safeCapture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
() => ({ secret: 'response-body-secret' })
);
expect(
events.every(
({ requestId }) => requestId === 'xtream-main-safe-fallback'
)
).toBe(true);
const serialized = JSON.stringify(events);
expect(serialized).not.toContain('provider-password-secret');
expect(serialized).not.toContain('response-body-secret');
expect(
events.every(
(event) =>
event.metadata === undefined &&
Object.keys(event).every((key) =>
[
'boundary',
'durationMs',
'epochMs',
'metadata',
'phase',
'requestId',
].includes(key)
)
)
).toBe(true);
});
it('correlates only the renderer-generated Xtream request ID shape', () => {
const events: MainPhaseEvent[] = [];
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => 'xtream-main-unused'),
readEpochMs: jest.fn(() => 100),
readMonotonicMs: jest.fn(() => 10),
};
const capture = requireCapture(runtime, events, 'xtream-mabc1234-17');
capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
() => undefined
);
expect(events.map(({ requestId }) => requestId)).toEqual([
'xtream-mabc1234-17',
'xtream-mabc1234-17',
]);
expect(runtime.createRequestId).not.toHaveBeenCalled();
});
it('keeps overlapping request phases isolated by capture-local request ID', async () => {
const events: MainPhaseEvent[] = [];
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => 'unused'),
readEpochMs: jest.fn(() => 100),
readMonotonicMs: jest.fn(() => 10),
};
const first = requireCapture(runtime, events, 'xtream-mabc1003-3');
const second = requireCapture(runtime, events, 'xtream-mabc1004-4');
let resolveFirst!: () => void;
let resolveSecond!: () => void;
const firstWork = new Promise<void>((resolve) => {
resolveFirst = resolve;
});
const secondWork = new Promise<void>((resolve) => {
resolveSecond = resolve;
});
const firstResult = first.measureAsync(
XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
() => firstWork
);
const secondResult = second.measureAsync(
XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
() => secondWork
);
resolveSecond();
await secondResult;
resolveFirst();
await firstResult;
for (const requestId of ['xtream-mabc1003-3', 'xtream-mabc1004-4']) {
const requestEvents = events.filter(
(event) => event.requestId === requestId
);
expect(requestEvents.map(({ boundary }) => boundary)).toEqual([
'start',
'end',
]);
}
});
});
@@ -0,0 +1,235 @@
import type {
AxiosResponseHeaders,
AxiosResponseTransformer,
InternalAxiosRequestConfig,
} from 'axios';
import {
XTREAM_MAIN_PERFORMANCE_PHASE,
type PerformancePhaseEvent,
type XtreamMainPerformancePhase,
} from '@iptvnator/shared/interfaces';
import {
createXtreamMainPerformanceCapture,
createXtreamMeasuredTransformResponse,
type XtreamMainPerformanceCapture,
type XtreamMainPerformanceCaptureRuntime,
} from './xtream-performance';
type MainPhaseEvent = PerformancePhaseEvent<XtreamMainPerformancePhase>;
function createRuntime(
requestId = 'xtream-main-generated'
): XtreamMainPerformanceCaptureRuntime {
let epochMs = 100;
let monotonicMs = 10;
return {
createRequestId: jest.fn(() => requestId),
readEpochMs: jest.fn(() => {
epochMs += 10;
return epochMs;
}),
readMonotonicMs: jest.fn(() => {
monotonicMs += 2;
return monotonicMs;
}),
};
}
function createCapture(
events: MainPhaseEvent[],
requestId = 'xtream-mabc1000-1',
runtime = createRuntime()
): XtreamMainPerformanceCapture {
const capture = createXtreamMainPerformanceCapture({
emit: (event) => events.push(event),
enabled: true,
requestId,
runtime,
});
if (!capture) {
throw new Error('Expected performance capture');
}
return capture;
}
describe('Xtream main-process performance capture', () => {
it('does no identifier, clock, or sink work while disabled', () => {
const runtime: XtreamMainPerformanceCaptureRuntime = {
createRequestId: jest.fn(() => {
throw new Error('identifier must stay untouched');
}),
readEpochMs: jest.fn(() => {
throw new Error('epoch clock must stay untouched');
}),
readMonotonicMs: jest.fn(() => {
throw new Error('monotonic clock must stay untouched');
}),
};
const emit = jest.fn(() => {
throw new Error('sink must stay untouched');
});
expect(
createXtreamMainPerformanceCapture({
emit,
enabled: false,
requestId: 'xtream-mabc1000-1',
runtime,
})
).toBeNull();
expect(runtime.createRequestId).not.toHaveBeenCalled();
expect(runtime.readEpochMs).not.toHaveBeenCalled();
expect(runtime.readMonotonicMs).not.toHaveBeenCalled();
expect(emit).not.toHaveBeenCalled();
});
it('preserves nested async results and exact errors while closing every phase', async () => {
const events: MainPhaseEvent[] = [];
const capture = createCapture(events);
const resultIdentity = { payload: 'same-object' };
const result = await capture.measureAsync(
XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
async () =>
capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
() => resultIdentity
)
);
expect(result).toBe(resultIdentity);
expect(
events.map(({ boundary, phase }) => ({ boundary, phase }))
).toEqual([
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
]);
const businessError = new Error('same-error');
expect(() =>
capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION,
() => {
throw businessError;
}
)
).toThrow(businessError);
expect(events.slice(-2).map(({ boundary }) => boundary)).toEqual([
'start',
'end',
]);
});
it('replays the exact Axios transform chain, context, headers, and status', () => {
const events: MainPhaseEvent[] = [];
const capture = createCapture(events);
const context = {
responseType: undefined,
} as InternalAxiosRequestConfig;
const headers = {
normalize: jest.fn(function () {
return this;
}),
} as unknown as AxiosResponseHeaders;
const firstResult = { step: 1 };
const finalResult = { step: 2 };
const observations: unknown[] = [];
const transforms: AxiosResponseTransformer[] = [
function (data, receivedHeaders, status) {
observations.push({
context: this,
data,
headers: receivedHeaders,
status,
});
return firstResult;
},
function (data, receivedHeaders, status) {
observations.push({
context: this,
data,
headers: receivedHeaders,
status,
});
return finalResult;
},
];
const measured = createXtreamMeasuredTransformResponse(
capture,
transforms
);
const result = measured.call(context, 'raw-json', headers, 200);
expect(result).toBe(finalResult);
expect(observations).toEqual([
{
context,
data: 'raw-json',
headers,
status: 200,
},
{
context,
data: firstResult,
headers,
status: 200,
},
]);
expect(headers.normalize).toHaveBeenCalledTimes(1);
expect(
events.filter(
({ phase }) =>
phase === XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM
)
).toHaveLength(2);
});
it('runs redirect transforms unchanged but emits one aggregate pair for the final response', () => {
const events: MainPhaseEvent[] = [];
const capture = createCapture(events);
const transform = jest.fn(function (data: unknown) {
return data;
}) as AxiosResponseTransformer;
const measured = createXtreamMeasuredTransformResponse(
capture,
transform
);
const context = {} as InternalAxiosRequestConfig;
const headers = {} as AxiosResponseHeaders;
expect(measured.call(context, 'redirect-body', headers, 302)).toBe(
'redirect-body'
);
expect(measured.call(context, 'final-body', headers, 200)).toBe(
'final-body'
);
expect(transform).toHaveBeenCalledTimes(2);
expect(
events.map(({ boundary, phase }) => ({ boundary, phase }))
).toEqual([
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
]);
});
});
@@ -0,0 +1,262 @@
import type {
AxiosResponseHeaders,
AxiosResponseTransformer,
InternalAxiosRequestConfig,
} from 'axios';
import {
PERFORMANCE_PHASE_BOUNDARY,
XTREAM_MAIN_PERFORMANCE_PHASE,
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL,
type PerformancePhaseEvent,
type XtreamMainPerformancePhase,
} from '@iptvnator/shared/interfaces';
import { randomUUID } from 'node:crypto';
import { channel, type Channel } from 'node:diagnostics_channel';
import { performance } from 'node:perf_hooks';
export interface XtreamMainPerformanceCaptureRuntime {
readonly createRequestId: () => string;
readonly readEpochMs: () => number;
readonly readMonotonicMs: () => number;
}
export interface XtreamMainPerformanceCapture {
measure<TResult>(
phase: XtreamMainPerformancePhase,
execute: () => TResult
): TResult;
measureAsync<TResult>(
phase: XtreamMainPerformancePhase,
execute: () => Promise<TResult>
): Promise<TResult>;
}
interface CaptureOptions {
readonly emit?: (event: MainPhaseEvent) => void;
readonly enabled: boolean;
readonly requestId?: string;
readonly runtime?: XtreamMainPerformanceCaptureRuntime;
}
interface PhaseStart {
readonly monotonicMs: number;
readonly phase: XtreamMainPerformancePhase;
}
type MainPhaseEvent = PerformancePhaseEvent<XtreamMainPerformancePhase>;
type TransformResponse =
AxiosResponseTransformer | AxiosResponseTransformer[] | undefined;
const DEFAULT_RUNTIME: XtreamMainPerformanceCaptureRuntime = {
createRequestId: () => `xtream-main-${randomUUID()}`,
readEpochMs: () => performance.timeOrigin + performance.now(),
readMonotonicMs: () => performance.now(),
};
const ENVIRONMENT_CAPTURE_ENABLED =
process.env['IPTVNATOR_PERF_CAPTURE'] === '1';
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const INTERNAL_REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const RENDERER_REQUEST_ID = /^xtream-[0-9a-z]+-[1-9][0-9]*$/;
const MAX_RENDERER_REQUEST_ID_LENGTH = 64;
let fallbackRequestSequence = 0;
let performanceChannel: Channel | null = null;
export function createXtreamMainPerformanceCaptureForRequest(
requestId?: string
): XtreamMainPerformanceCapture | null {
return ENVIRONMENT_CAPTURE_ENABLED
? createXtreamMainPerformanceCapture({
enabled: true,
requestId,
})
: null;
}
export function createXtreamMainPerformanceCapture(
options: CaptureOptions
): XtreamMainPerformanceCapture | null {
if (!options.enabled) {
return null;
}
const runtime = options.runtime ?? DEFAULT_RUNTIME;
const requestId =
normalizeRendererRequestId(options.requestId) ??
createRequestId(runtime);
const emit = options.emit ?? publishXtreamMainPerformancePhaseEvent;
let lastEpochMs = 0;
const readEpochMs = (): number => {
lastEpochMs = safeReadEpochMs(runtime, lastEpochMs);
return lastEpochMs;
};
const begin = (phase: XtreamMainPerformancePhase): PhaseStart => {
const monotonicMs = safeReadMonotonicMs(runtime);
safeEmit(emit, {
boundary: PERFORMANCE_PHASE_BOUNDARY.START,
durationMs: null,
epochMs: readEpochMs(),
metadata: undefined,
phase,
requestId,
});
return { monotonicMs, phase };
};
const end = (start: PhaseStart): void => {
const endedMonotonicMs = safeReadMonotonicMs(runtime);
const durationMs = Math.max(0, endedMonotonicMs - start.monotonicMs);
safeEmit(emit, {
boundary: PERFORMANCE_PHASE_BOUNDARY.END,
durationMs: Number.isFinite(durationMs) ? durationMs : 0,
epochMs: readEpochMs(),
metadata: undefined,
phase: start.phase,
requestId,
});
};
return {
measure: (phase, execute) => {
const start = begin(phase);
try {
return execute();
} finally {
end(start);
}
},
measureAsync: async (phase, execute) => {
const start = begin(phase);
try {
return await execute();
} finally {
end(start);
}
},
};
}
export function createXtreamMeasuredTransformResponse(
capture: XtreamMainPerformanceCapture,
configuredTransforms: TransformResponse
): AxiosResponseTransformer {
const transforms = normalizeTransforms(configuredTransforms);
return function measuredTransformResponse(data, headers, status) {
const execute = () =>
runTransformChain(transforms, this, data, headers, status);
return status !== undefined && REDIRECT_STATUSES.has(status)
? execute()
: capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
execute
);
};
}
function runTransformChain(
transforms: readonly AxiosResponseTransformer[],
context: InternalAxiosRequestConfig,
initialData: unknown,
headers: AxiosResponseHeaders,
status: number | undefined
): unknown {
let data = initialData;
for (let index = 0; index < transforms.length; index += 1) {
const transform = transforms[index];
const normalizedHeaders =
index === 0 ? headers : normalizeHeaders(headers);
data = transform.call(context, data, normalizedHeaders, status);
}
return data;
}
function normalizeTransforms(
transforms: TransformResponse
): readonly AxiosResponseTransformer[] {
if (!transforms) {
return [];
}
return Array.isArray(transforms) ? transforms.slice() : [transforms];
}
function normalizeHeaders(headers: AxiosResponseHeaders): AxiosResponseHeaders {
const normalize = (
headers as AxiosResponseHeaders & {
normalize?: () => AxiosResponseHeaders;
}
).normalize;
return typeof normalize === 'function' ? normalize.call(headers) : headers;
}
function publishXtreamMainPerformancePhaseEvent(event: MainPhaseEvent): void {
try {
performanceChannel ??= channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
performanceChannel.publish(event);
} catch {
// Development-only instrumentation must never affect Xtream requests.
}
}
function createRequestId(runtime: XtreamMainPerformanceCaptureRuntime): string {
try {
const requestId = normalizeInternalRequestId(runtime.createRequestId());
if (requestId) {
return requestId;
}
} catch {
// Fall through to a process-local, credential-free identifier.
}
fallbackRequestSequence += 1;
return `xtream-main-fallback-${fallbackRequestSequence}`;
}
function normalizeRendererRequestId(value: unknown): string | null {
return typeof value === 'string' &&
value.length <= MAX_RENDERER_REQUEST_ID_LENGTH &&
RENDERER_REQUEST_ID.test(value)
? value
: null;
}
function normalizeInternalRequestId(value: unknown): string | null {
return typeof value === 'string' && INTERNAL_REQUEST_ID.test(value)
? value
: null;
}
function safeReadEpochMs(
runtime: XtreamMainPerformanceCaptureRuntime,
previousEpochMs: number
): number {
try {
const value = runtime.readEpochMs();
return Number.isFinite(value) && value > 0
? Math.max(previousEpochMs, value)
: Math.max(previousEpochMs, 1);
} catch {
return Math.max(previousEpochMs, 1);
}
}
function safeReadMonotonicMs(
runtime: XtreamMainPerformanceCaptureRuntime
): number {
try {
const value = runtime.readMonotonicMs();
return Number.isFinite(value) && value >= 0 ? value : 0;
} catch {
return 0;
}
}
function safeEmit(
emit: (event: MainPhaseEvent) => void,
event: MainPhaseEvent
) {
try {
emit(event);
} catch {
// Instrumentation delivery is deliberately fail-neutral.
}
}
@@ -0,0 +1,26 @@
interface ActiveXtreamRequest {
readonly controller: AbortController;
readonly sessionId?: string;
}
interface XtreamCancellationResult {
readonly cancelled: number;
readonly success: boolean;
}
export function cancelXtreamSessionRequests(
activeRequests: Iterable<ActiveXtreamRequest>,
sessionId: string
): XtreamCancellationResult {
if (!sessionId) {
return { success: false, cancelled: 0 };
}
let cancelled = 0;
for (const activeRequest of activeRequests) {
if (activeRequest.sessionId === sessionId) {
activeRequest.controller.abort();
cancelled += 1;
}
}
return { success: cancelled > 0, cancelled };
}
@@ -0,0 +1,204 @@
import type { AxiosRequestConfig } from 'axios';
import {
XTREAM_MAIN_PERFORMANCE_PHASE,
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL,
type PerformancePhaseEvent,
type XtreamMainPerformancePhase,
} from '@iptvnator/shared/interfaces';
import { channel } from 'node:diagnostics_channel';
import {
applyConfiguredTransforms,
axiosMock,
getHandler,
loadXtreamEvents,
mockParseTransform,
} from './xtream.events.performance.test-helpers';
type MainPhaseEvent = PerformancePhaseEvent<XtreamMainPerformancePhase>;
function expectFailedResponsePhases(events: MainPhaseEvent[]): void {
expect(events.map(({ boundary, phase }) => ({ boundary, phase }))).toEqual([
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
]);
expect(
events.some(
({ phase }) =>
phase === XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY
)
).toBe(false);
}
describe('XtreamEvents failed-response performance phases', () => {
const originalCaptureValue = process.env['IPTVNATOR_PERF_CAPTURE'];
afterEach(() => {
if (originalCaptureValue === undefined) {
delete process.env['IPTVNATOR_PERF_CAPTURE'];
} else {
process.env['IPTVNATOR_PERF_CAPTURE'] = originalCaptureValue;
}
});
it('closes JSON and network phases without marking an Axios error response ready', async () => {
await loadXtreamEvents('1');
const events: MainPhaseEvent[] = [];
const phaseChannel = channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
const subscriber = (event: unknown) =>
events.push(event as MainPhaseEvent);
phaseChannel.subscribe(subscriber);
try {
let axiosError: Error & {
response?: {
data: unknown;
headers: object;
status: number;
statusText: string;
};
};
axiosMock.mockImplementation((config: AxiosRequestConfig) => {
axiosError = Object.assign(new Error('provider unavailable'), {
response: {
data: applyConfiguredTransforms(
config,
'{"message":"provider unavailable"}',
503
),
headers: {},
status: 503,
statusText: 'Unavailable',
},
});
axiosMock.isAxiosError.mockImplementation(
(error: unknown) => error === axiosError
);
return Promise.reject(axiosError);
});
await expect(
getHandler('XTREAM_REQUEST')(
{},
{
params: {
action: 'get_live_categories',
password: 'provider-password-secret',
username: 'provider-user-secret',
},
requestId: 'xtream-mabc1007-7',
sessionId: 'xtream-session-error',
suppressErrorLog: true,
url: 'http://localhost:3211',
}
)
).rejects.toEqual({
message: 'provider unavailable',
status: 503,
type: 'ERROR',
});
expectFailedResponsePhases(events);
} finally {
phaseChannel.unsubscribe(subscriber);
}
});
it('closes a fulfilled 4xx response without marking an IPC result ready', async () => {
await loadXtreamEvents('1');
const events: MainPhaseEvent[] = [];
const phaseChannel = channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
const subscriber = (event: unknown) =>
events.push(event as MainPhaseEvent);
phaseChannel.subscribe(subscriber);
try {
axiosMock.mockImplementation((config: AxiosRequestConfig) =>
Promise.resolve({
config,
data: applyConfiguredTransforms(
config,
'{"message":"not found"}',
404
),
headers: {},
status: 404,
statusText: 'Not Found',
})
);
await expect(
getHandler('XTREAM_REQUEST')(
{},
{
params: { action: 'get_live_categories' },
requestId: 'xtream-mabc1008-8',
suppressErrorLog: true,
url: 'http://localhost:3211',
}
)
).rejects.toEqual({
message: 'HTTP Error: Not Found',
status: 404,
});
expectFailedResponsePhases(events);
} finally {
phaseChannel.unsubscribe(subscriber);
}
});
it('preserves an exact default-transform error while closing JSON and network once', async () => {
await loadXtreamEvents('1');
const transformError = new Error('default transform failed');
mockParseTransform.mockImplementationOnce(() => {
throw transformError;
});
axiosMock.mockImplementation((config: AxiosRequestConfig) =>
Promise.resolve({
config,
data: applyConfiguredTransforms(config, '{"items":[]}', 200),
headers: {},
status: 200,
statusText: 'OK',
})
);
const events: MainPhaseEvent[] = [];
const phaseChannel = channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
const subscriber = (event: unknown) =>
events.push(event as MainPhaseEvent);
phaseChannel.subscribe(subscriber);
try {
await expect(
getHandler('XTREAM_REQUEST')(
{},
{
params: { action: 'get_live_categories' },
requestId: 'xtream-mabc1009-9',
suppressErrorLog: true,
url: 'http://localhost:3211',
}
)
).rejects.toBe(transformError);
expectFailedResponsePhases(events);
} finally {
phaseChannel.unsubscribe(subscriber);
}
});
});
@@ -0,0 +1,254 @@
import type { AxiosRequestConfig } from 'axios';
import {
XTREAM_CANCEL_SESSION,
XTREAM_MAIN_PERFORMANCE_PHASE,
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL,
type PerformancePhaseEvent,
type XtreamMainPerformancePhase,
} from '@iptvnator/shared/interfaces';
import { channel } from 'node:diagnostics_channel';
import {
applyConfiguredTransforms,
axiosMock,
createDeferred,
getDefaultsReadCount,
getHandler,
loadXtreamEvents,
mockFinalizeTransform,
mockParseTransform,
} from './xtream.events.performance.test-helpers';
type MainPhaseEvent = PerformancePhaseEvent<XtreamMainPerformancePhase>;
describe('XtreamEvents performance phases', () => {
const originalCaptureValue = process.env['IPTVNATOR_PERF_CAPTURE'];
afterEach(() => {
if (originalCaptureValue === undefined) {
delete process.env['IPTVNATOR_PERF_CAPTURE'];
} else {
process.env['IPTVNATOR_PERF_CAPTURE'] = originalCaptureValue;
}
jest.restoreAllMocks();
});
it.each([undefined, 'true', 'yes', 'on', ' 1 ', '0'])(
'keeps the Axios path untouched for non-exact capture value %p',
async (captureValue) => {
const events: MainPhaseEvent[] = [];
const phaseChannel = channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
const subscriber = (event: unknown) =>
events.push(event as MainPhaseEvent);
phaseChannel.subscribe(subscriber);
try {
await loadXtreamEvents(captureValue);
axiosMock.mockImplementation((config: AxiosRequestConfig) =>
Promise.resolve({
config,
data: { same: 'identity' },
headers: {},
status: 200,
statusText: 'OK',
})
);
await getHandler('XTREAM_REQUEST')(
{},
{
params: { action: 'get_live_categories' },
suppressErrorLog: true,
url: 'http://localhost:3211',
}
);
const config = axiosMock.mock.calls[0][0];
expect(
Object.prototype.hasOwnProperty.call(
config,
'transformResponse'
)
).toBe(false);
expect(getDefaultsReadCount()).toBe(0);
expect(events).toEqual([]);
} finally {
phaseChannel.unsubscribe(subscriber);
}
}
);
it('pairs one final JSON phase across redirects and marks the ready response', async () => {
await loadXtreamEvents('1');
const events: MainPhaseEvent[] = [];
const phaseChannel = channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
const subscriber = (event: unknown) =>
events.push(event as MainPhaseEvent);
phaseChannel.subscribe(subscriber);
let finalPayload: unknown;
try {
axiosMock
.mockImplementationOnce((config: AxiosRequestConfig) =>
Promise.resolve({
config,
data: applyConfiguredTransforms(
config,
'{"redirect":true}',
302
),
headers: { location: '/final' },
status: 302,
statusText: 'Found',
})
)
.mockImplementationOnce((config: AxiosRequestConfig) => {
finalPayload = applyConfiguredTransforms(
config,
'{"items":[1,2,3]}',
200
);
return Promise.resolve({
config,
data: finalPayload,
headers: {},
status: 200,
statusText: 'OK',
});
});
const result = (await getHandler('XTREAM_REQUEST')(
{},
{
params: {
action: 'get_live_categories',
password: 'provider-password-secret',
username: 'provider-user-secret',
},
requestId: 'xtream-mabc1005-5',
sessionId: 'xtream-session-1',
suppressErrorLog: true,
url: 'http://localhost:3211',
}
)) as { action: string; payload: unknown };
expect(result.payload).toBe(finalPayload);
expect(result.action).toBe('get_live_categories');
expect(getDefaultsReadCount()).toBe(1);
expect(mockParseTransform).toHaveBeenCalledTimes(2);
expect(mockFinalizeTransform).toHaveBeenCalledTimes(2);
expect(
events.map(({ boundary, phase }) => ({ boundary, phase }))
).toEqual([
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
},
]);
const serialized = JSON.stringify(events);
expect(serialized).not.toContain('localhost');
expect(serialized).not.toContain('provider-user-secret');
expect(serialized).not.toContain('provider-password-secret');
expect(serialized).not.toContain('items');
} finally {
phaseChannel.unsubscribe(subscriber);
}
});
it('closes network and cancel dispatch exactly once without a response-ready marker on abort', async () => {
await loadXtreamEvents('1');
const enteredAxios = createDeferred<void>();
const cancelError = Object.assign(new Error('cancelled'), {
code: 'ERR_CANCELED',
});
let signal: AbortSignal | undefined;
axiosMock.mockImplementation((config: AxiosRequestConfig) => {
signal = config.signal as AbortSignal;
enteredAxios.resolve();
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(cancelError), {
once: true,
});
});
});
axiosMock.isAxiosError.mockImplementation(
(error: unknown) => error === cancelError
);
const events: MainPhaseEvent[] = [];
const phaseChannel = channel(
XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL
);
const subscriber = (event: unknown) =>
events.push(event as MainPhaseEvent);
phaseChannel.subscribe(subscriber);
try {
const request = getHandler('XTREAM_REQUEST')(
{},
{
params: {
action: 'get_live_streams',
password: 'secret',
username: 'user',
},
requestId: 'xtream-mabc1006-6',
sessionId: 'xtream-session-abort',
suppressErrorLog: true,
url: 'http://localhost:3211',
}
) as Promise<unknown>;
await enteredAxios.promise;
await expect(
getHandler(XTREAM_CANCEL_SESSION)({}, 'xtream-session-abort')
).resolves.toEqual({ cancelled: 1, success: true });
await expect(request).rejects.toMatchObject({
name: 'AbortError',
status: 499,
});
expect(signal?.aborted).toBe(true);
expect(
events.map(({ boundary, phase }) => ({ boundary, phase }))
).toEqual([
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
{
boundary: 'start',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION,
},
{
boundary: 'end',
phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
},
]);
} finally {
phaseChannel.unsubscribe(subscriber);
}
});
});
@@ -0,0 +1,118 @@
import type {
AxiosRequestConfig,
AxiosResponseHeaders,
AxiosResponseTransformer,
InternalAxiosRequestConfig,
} from 'axios';
const registeredHandlers = new Map<string, (...args: unknown[]) => unknown>();
export const mockParseTransform = jest.fn(function (data: unknown) {
return typeof data === 'string' ? JSON.parse(data) : data;
});
export const mockFinalizeTransform = jest.fn(function (data: unknown) {
return data;
});
const mockDefaultTransforms: AxiosResponseTransformer[] = [
mockParseTransform,
mockFinalizeTransform,
];
let mockDefaultsReadCount = 0;
export const axiosMock = Object.assign(jest.fn(), {
isAxiosError: jest.fn(),
});
Object.defineProperty(axiosMock, 'defaults', {
configurable: true,
get: () => {
mockDefaultsReadCount += 1;
return { transformResponse: mockDefaultTransforms };
},
});
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(
(name: string, handler: (...args: unknown[]) => unknown) => {
registeredHandlers.set(name, handler);
}
),
},
}));
jest.mock('axios', () => ({
__esModule: true,
default: axiosMock,
}));
jest.mock('./portal-debug.events', () => ({
emitPortalDebugEvent: jest.fn(),
}));
export function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
export function applyConfiguredTransforms(
config: AxiosRequestConfig,
data: unknown,
status: number
): unknown {
const configured = config.transformResponse;
if (!configured) {
return data;
}
const headers = {
normalize: jest.fn(function () {
return this;
}),
} as unknown as AxiosResponseHeaders & {
normalize: () => AxiosResponseHeaders;
};
const transforms = Array.isArray(configured) ? configured : [configured];
let transformed = data;
for (const transform of transforms) {
transformed = transform.call(
config as InternalAxiosRequestConfig,
transformed,
headers.normalize(),
status
);
}
headers.normalize();
return transformed;
}
export async function loadXtreamEvents(
captureValue: string | undefined
): Promise<void> {
jest.resetModules();
registeredHandlers.clear();
axiosMock.mockReset();
axiosMock.isAxiosError.mockReset();
mockParseTransform.mockClear();
mockFinalizeTransform.mockClear();
mockDefaultsReadCount = 0;
if (captureValue === undefined) {
delete process.env['IPTVNATOR_PERF_CAPTURE'];
} else {
process.env['IPTVNATOR_PERF_CAPTURE'] = captureValue;
}
await import('./xtream.events');
}
export function getHandler(name: string): (...args: unknown[]) => unknown {
const handler = registeredHandlers.get(name);
if (!handler) {
throw new Error(`Missing IPC handler: ${name}`);
}
return handler;
}
export function getDefaultsReadCount(): number {
return mockDefaultsReadCount;
}
@@ -4,6 +4,8 @@ const registeredHandlers = new Map<string, (...args: unknown[]) => unknown>();
const axiosMock = Object.assign(jest.fn(), {
isAxiosError: jest.fn(),
});
const PERF_CAPTURE_ENV = 'IPTVNATOR_PERF_CAPTURE';
const originalPerformanceCaptureValue = process.env[PERF_CAPTURE_ENV];
function createDeferred<T>() {
let resolve!: (value: T) => void;
@@ -40,6 +42,7 @@ describe('XtreamEvents session cancellation', () => {
beforeEach(async () => {
jest.resetModules();
delete process.env[PERF_CAPTURE_ENV];
registeredHandlers.clear();
axiosMock.mockReset();
axiosMock.isAxiosError.mockReset();
@@ -52,6 +55,14 @@ describe('XtreamEvents session cancellation', () => {
consoleErrorSpy.mockRestore();
});
afterAll(() => {
if (originalPerformanceCaptureValue === undefined) {
delete process.env[PERF_CAPTURE_ENV];
} else {
process.env[PERF_CAPTURE_ENV] = originalPerformanceCaptureValue;
}
});
it('normalizes full Xtream API URLs before appending player_api.php', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
expect(requestHandler).toBeDefined();
@@ -8,12 +8,18 @@ import { ipcMain } from 'electron';
import {
PortalDebugEvent,
XTREAM_CANCEL_SESSION,
XTREAM_MAIN_PERFORMANCE_PHASE,
normalizeXtreamServerUrl,
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { emitPortalDebugEvent } from './portal-debug.events';
import { UnsafeUrlError } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
import {
createXtreamMainPerformanceCaptureForRequest,
createXtreamMeasuredTransformResponse,
} from './xtream-performance';
import { cancelXtreamSessionRequests } from './xtream-session-cancellation';
// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to
// challenge generic browser-looking User-Agents while allowlisting known
@@ -101,6 +107,9 @@ ipcMain.handle(
}
) => {
const startedAt = Date.now();
const performanceCapture = createXtreamMainPerformanceCaptureForRequest(
payload.requestId
);
let activeRequestKey: string | null = null;
let requestUrlForLog = payload.url;
try {
@@ -132,13 +141,29 @@ ipcMain.handle(
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
signal: controller.signal,
};
if (performanceCapture) {
config.transformResponse =
createXtreamMeasuredTransformResponse(
performanceCapture,
axios.defaults.transformResponse
);
}
const response = await requestWithValidatedRedirects<unknown>(
apiUrl.toString(),
config,
{ allowPrivateNetworks: true }
);
const response = performanceCapture
? await performanceCapture.measureAsync(
XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL,
() =>
requestWithValidatedRedirects<unknown>(
apiUrl.toString(),
config,
{ allowPrivateNetworks: true }
)
)
: await requestWithValidatedRedirects<unknown>(
apiUrl.toString(),
config,
{ allowPrivateNetworks: true }
);
// Check if response is successful
if (response.status >= 400) {
throw {
@@ -169,10 +194,16 @@ ipcMain.handle(
}
// Xtream API returns JSON data
return {
const result = {
payload: response.data,
action: params.action,
};
return performanceCapture
? performanceCapture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY,
() => result
)
: result;
} catch (error) {
const requestId = payload.requestId;
if (requestId) {
@@ -269,24 +300,20 @@ ipcMain.handle(
_event,
sessionId: string
): Promise<{ success: boolean; cancelled: number }> => {
if (!sessionId) {
return { success: false, cancelled: 0 };
}
let cancelled = 0;
for (const activeRequest of activeXtreamRequests.values()) {
if (activeRequest.sessionId !== sessionId) {
continue;
}
activeRequest.controller.abort();
cancelled += 1;
}
return {
success: cancelled > 0,
cancelled,
};
const capture = createXtreamMainPerformanceCaptureForRequest();
return capture
? capture.measure(
XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION,
() =>
cancelXtreamSessionRequests(
activeXtreamRequests.values(),
sessionId
)
)
: cancelXtreamSessionRequests(
activeXtreamRequests.values(),
sessionId
);
}
);
type ActiveXtreamRequest = {
@@ -329,8 +356,7 @@ ipcMain.handle(
}
);
const responseBody = response.data as
| { destroy?: () => void }
| undefined;
{ destroy?: () => void } | undefined;
responseBody?.destroy?.();
return {
status: response.status,
@@ -148,6 +148,40 @@ describe('DatabaseWorkerClient', () => {
});
});
it('ignores profiling-only cancel receipts without settling or exposing the pending request', async () => {
const client = createClient();
const onEvent = jest.fn();
const requestPromise = client.request(
'DB_DELETE_XTREAM_CONTENT',
{ operationId: 'operation-1', playlistId: 'xtream-1' },
{ onEvent }
);
const worker = mockWorkerInstances[0];
worker.emit('message', { type: 'ready' });
await flushPromises();
const request = worker.postMessage.mock.calls[0][0];
worker.emit('message', {
type: 'performance-cancel-received',
operationId: 'operation-1',
requestId: request.requestId,
epochMs: 123,
});
expect(onEvent).not.toHaveBeenCalled();
const resultIdentity = { success: true };
worker.emit('message', {
type: 'response',
requestId: request.requestId,
success: true,
result: resultIdentity,
});
await expect(requestPromise).resolves.toBe(resultIdentity);
});
it('turns serialized worker errors into rejected Error instances', async () => {
const client = createClient();
const requestPromise = client.request('DB_DELETE_PLAYLIST', {
@@ -194,7 +194,13 @@ export class DatabaseWorkerClient {
requestId: message.requestId,
});
}
this.pendingRequests.get(message.requestId)?.onEvent?.(message.event);
this.pendingRequests
.get(message.requestId)
?.onEvent?.(message.event);
return;
}
if (message.type === 'performance-cancel-received') {
return;
}
@@ -0,0 +1,120 @@
import { publishDatabaseWorkerCancelReceipt } from './database-worker-cancel-receipt';
const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING';
const originalProfilingValue = process.env[PROFILING_ENV];
describe('database worker performance cancellation receipt', () => {
beforeEach(() => {
delete process.env[PROFILING_ENV];
});
afterAll(() => {
if (originalProfilingValue === undefined) {
delete process.env[PROFILING_ENV];
} else {
process.env[PROFILING_ENV] = originalProfilingValue;
}
});
it('does no clock or transport work without the exact profiling opt-in', () => {
const publish = jest.fn();
const readEpochMs = jest.fn(() => 123);
process.env[PROFILING_ENV] = 'true';
publishDatabaseWorkerCancelReceipt(
'operation-1',
'request-1',
publish,
{ readEpochMs }
);
expect(readEpochMs).not.toHaveBeenCalled();
expect(publish).not.toHaveBeenCalled();
});
it('publishes only safe correlation IDs and a finite epoch', () => {
const publish = jest.fn();
process.env[PROFILING_ENV] = '1';
publishDatabaseWorkerCancelReceipt(
'playlist-delete:123_abc',
'08da28c4-cc14-4a79-95ec-dff6bba10db0',
publish,
{ readEpochMs: () => 1_234.5 }
);
expect(publish).toHaveBeenCalledWith({
type: 'performance-cancel-received',
operationId: 'playlist-delete:123_abc',
requestId: '08da28c4-cc14-4a79-95ec-dff6bba10db0',
epochMs: 1_234.5,
});
expect(
Object.keys(publish.mock.calls[0][0] as Record<string, unknown>)
).toEqual(['type', 'operationId', 'requestId', 'epochMs']);
});
it.each([
[' leading-space', 'request-1'],
['trailing-space ', 'request-1'],
['provider/password', 'request-1'],
['operation-1', 'request with spaces'],
['operation-1', 'request/with/slashes'],
[`a${'b'.repeat(128)}`, 'request-1'],
])(
'rejects unsafe operation/request correlation without transport work',
(operationId, requestId) => {
const publish = jest.fn();
const readEpochMs = jest.fn(() => 123);
process.env[PROFILING_ENV] = '1';
publishDatabaseWorkerCancelReceipt(
operationId,
requestId,
publish,
{ readEpochMs }
);
expect(readEpochMs).not.toHaveBeenCalled();
expect(publish).not.toHaveBeenCalled();
}
);
it('fails neutrally when the clock or diagnostic transport fails', () => {
process.env[PROFILING_ENV] = '1';
const publish = jest.fn(() => {
throw new Error('transport unavailable');
});
expect(() =>
publishDatabaseWorkerCancelReceipt(
'operation-1',
'request-1',
publish,
{ readEpochMs: () => 123 }
)
).not.toThrow();
expect(() =>
publishDatabaseWorkerCancelReceipt(
'operation-1',
'request-1',
publish,
{
readEpochMs: () => {
throw new Error('clock unavailable');
},
}
)
).not.toThrow();
expect(() =>
publishDatabaseWorkerCancelReceipt(
'operation-1',
'request-1',
publish,
{ readEpochMs: () => Number.NaN }
)
).not.toThrow();
expect(publish).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,63 @@
import { performance } from 'node:perf_hooks';
import type { DbWorkerPerformanceCancelReceivedMessage } from './database-worker.types';
import { WORKER_PROFILING_ENV } from './worker-performance-capture.runtime';
const MAX_IDENTIFIER_LENGTH = 128;
const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
interface CancelReceiptRuntime {
readonly readEpochMs: () => number;
}
type CancelReceiptPublisher = (
message: DbWorkerPerformanceCancelReceivedMessage
) => void;
const DEFAULT_RUNTIME: CancelReceiptRuntime = {
readEpochMs: () => performance.timeOrigin + performance.now(),
};
export function publishDatabaseWorkerCancelReceipt(
operationId: unknown,
requestId: unknown,
publish: CancelReceiptPublisher,
runtime: CancelReceiptRuntime = DEFAULT_RUNTIME
): void {
if (process.env[WORKER_PROFILING_ENV] !== '1') {
return;
}
if (!isSafeIdentifier(operationId) || !isSafeIdentifier(requestId)) {
return;
}
let epochMs: number;
try {
epochMs = runtime.readEpochMs();
} catch {
return;
}
if (!Number.isFinite(epochMs) || epochMs <= 0) {
return;
}
try {
publish({
type: 'performance-cancel-received',
operationId,
requestId,
epochMs,
});
} catch {
// Development-only instrumentation must not affect cancellation.
}
}
function isSafeIdentifier(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= MAX_IDENTIFIER_LENGTH &&
value === value.trim() &&
SAFE_IDENTIFIER_PATTERN.test(value)
);
}
@@ -88,11 +88,7 @@ export const DB_OPERATION_PHASES = {
} as const;
export type DbOperationStatus =
| 'started'
| 'progress'
| 'completed'
| 'cancelled'
| 'error';
'started' | 'progress' | 'completed' | 'cancelled' | 'error';
export interface DbOperationEvent {
operationId?: string;
@@ -134,6 +130,13 @@ export interface DbWorkerEventMessage {
event: DbOperationEvent;
}
export interface DbWorkerPerformanceCancelReceivedMessage {
type: 'performance-cancel-received';
operationId: string;
requestId: string;
epochMs: number;
}
export interface DbWorkerResponseMessage<TResult = unknown> {
type: 'response';
requestId: string;
@@ -144,10 +147,10 @@ export interface DbWorkerResponseMessage<TResult = unknown> {
}
export type DbWorkerIncomingMessage =
| DbWorkerRequestMessage
| DbWorkerCancelMessage;
DbWorkerRequestMessage | DbWorkerCancelMessage;
export type DbWorkerMessage =
| DbWorkerReadyMessage
| DbWorkerEventMessage
| DbWorkerPerformanceCancelReceivedMessage
| DbWorkerResponseMessage;
@@ -102,11 +102,13 @@ import {
import {
captureWorkerPerformancePhase,
captureWorkerPerformancePhaseAsync,
createWorkerPerformancePhaseAdapter,
} from './worker-performance-phase';
import {
handleDatabaseWorkerPostGcHeapRequest,
isDatabaseWorkerPostGcHeapRequest,
} from './database-worker-post-gc-heap';
import { publishDatabaseWorkerCancelReceipt } from './database-worker-cancel-receipt';
const loggerLabel = '[DB Worker]';
const batchDelayMs = Number.parseInt(
@@ -116,6 +118,7 @@ const batchDelayMs = Number.parseInt(
type ActiveOperationState = {
cancelled: boolean;
performanceRequestId?: string;
};
type PreRegisteredActiveOperation = {
@@ -376,7 +379,14 @@ async function executeRequest(
playlistId: string;
type: 'live' | 'movies' | 'series';
};
return getCategories(db, payload.playlistId, payload.type);
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return getCategories(
db,
payload.playlistId,
payload.type,
capturePhase
);
}
case 'DB_SAVE_CATEGORIES': {
@@ -389,12 +399,15 @@ async function executeRequest(
type: 'live' | 'movies' | 'series';
hiddenCategoryXtreamIds?: number[];
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return saveCategories(
db,
payload.playlistId,
payload.categories,
payload.type,
payload.hiddenCategoryXtreamIds
payload.hiddenCategoryXtreamIds,
capturePhase
);
}
@@ -431,7 +444,14 @@ async function executeRequest(
playlistId: string;
type: 'live' | 'movie' | 'series';
};
return getContent(db, payload.playlistId, payload.type);
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return getContent(
db,
payload.playlistId,
payload.type,
capturePhase
);
}
case 'DB_GET_GLOBAL_RECENTLY_ADDED': {
@@ -456,6 +476,8 @@ async function executeRequest(
type: 'live' | 'movie' | 'series';
operationId?: string;
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return executeTrackedOperation(
{
@@ -476,7 +498,8 @@ async function executeRequest(
payload.playlistId,
payload.streams,
payload.type,
controller.control
controller.control,
capturePhase
);
controller.emitCompleted({
@@ -496,8 +519,15 @@ async function executeRequest(
playlistId: string;
type: 'live' | 'movie' | 'series';
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return clearXtreamImportCache(db, payload.playlistId, payload.type);
return clearXtreamImportCache(
db,
payload.playlistId,
payload.type,
capturePhase
);
}
case 'DB_GET_CONTENT_BY_XTREAM_ID': {
@@ -533,12 +563,15 @@ async function executeRequest(
types: string[];
excludeHidden?: boolean;
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return searchContent(
db,
payload.playlistId,
payload.searchTerm,
payload.types,
payload.excludeHidden
payload.excludeHidden,
capturePhase
);
}
@@ -672,6 +705,8 @@ async function executeRequest(
playlistId: string;
operationId?: string;
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return executeTrackedOperation(
{
@@ -689,7 +724,8 @@ async function executeRequest(
const result = await deletePlaylist(
db,
payload.playlistId,
controller.control
controller.control,
capturePhase
);
controller.emitCompleted({
@@ -784,6 +820,8 @@ async function executeRequest(
playlistId: string;
operationId?: string;
};
const capturePhase =
createWorkerPerformancePhaseAdapter(performanceCapture);
return executeTrackedOperation(
{
@@ -801,7 +839,8 @@ async function executeRequest(
const result = await deleteXtreamContent(
db,
payload.playlistId,
controller.control
controller.control,
capturePhase
);
controller.emitCompleted({
@@ -1049,6 +1088,13 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
const activeOperation = activeOperations.get(message.operationId);
if (activeOperation) {
activeOperation.cancelled = true;
if (activeOperation.performanceRequestId !== undefined) {
publishDatabaseWorkerCancelReceipt(
message.operationId,
activeOperation.performanceRequestId,
postMessage
);
}
}
return;
}
@@ -1060,6 +1106,10 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => {
performanceCapture = startWorkerPerformanceCapture({
requestId: message.requestId,
});
if (performanceCapture && preRegisteredOperation) {
preRegisteredOperation.state.performanceRequestId =
message.requestId;
}
registerDatabaseWorkerPerformanceCapture(
activePerformanceCaptures,
performanceCapture
@@ -6,91 +6,17 @@ import type {
PlaylistRefreshWorkerIncomingMessage,
PlaylistRefreshWorkerMessage,
} from './playlist-refresh.worker.types';
type WorkerMessageHandler<TMessage> = (
message: TMessage
) => Promise<void> | void;
interface ArmGate {
readonly promise: Promise<void>;
readonly release: () => void;
}
function createArmGate(): ArmGate {
let release = (): void => undefined;
const promise = new Promise<void>((resolvePromise) => {
release = resolvePromise;
});
return { promise, release };
}
function createParentPortHarness<TIncoming, TOutgoing>() {
let messageHandler: WorkerMessageHandler<TIncoming> | null = null;
const postMessage = jest.fn<void, [TOutgoing]>();
const parentPort = {
on: jest.fn(
(event: string, handler: WorkerMessageHandler<TIncoming>): void => {
if (event === 'message') {
messageHandler = handler;
}
}
),
postMessage,
};
return {
getMessageHandler: (): WorkerMessageHandler<TIncoming> => {
if (!messageHandler) {
throw new Error('Worker message handler was not registered');
}
return messageHandler;
},
parentPort,
postMessage,
};
}
function mockPerformanceCapture(
armGate: ArmGate,
failureGates?: {
readonly observed: ArmGate;
readonly profilingFinished: ArmGate;
}
): void {
jest.doMock('./worker-performance-capture', () => ({
armWorkerPerformanceCapture: jest.fn(() => armGate.promise),
executeWithWorkerPerformanceCapture: jest.fn(
async (_capture: unknown, execute: () => Promise<unknown>) => {
try {
return {
error: null,
performance: undefined,
result: await execute(),
success: true,
};
} catch (error) {
failureGates?.observed.release();
await failureGates?.profilingFinished.promise;
return {
error,
performance: undefined,
result: undefined,
success: false,
};
}
}
),
registerDatabaseWorkerPerformanceCapture: jest.fn(),
releaseDatabaseWorkerPerformanceCapture: jest.fn(),
stampWorkerPerformanceResponsePostedEpoch: jest.fn(
(_capture: unknown, performance: unknown) => performance
),
startWorkerPerformanceCapture: jest.fn(() => ({})),
}));
}
import {
createArmGate,
createParentPortHarness,
mockPerformanceCapture,
restoreWorkerProfilingEnvironment,
WORKER_PROFILING_ENV,
} from './worker-performance-cancellation.test-helpers';
describe('worker cancellation while performance capture arms', () => {
afterEach(() => {
restoreWorkerProfilingEnvironment();
jest.restoreAllMocks();
jest.resetModules();
});
@@ -175,7 +101,8 @@ describe('worker cancellation while performance capture arms', () => {
);
});
it('cancels cancellable database work when cancel arrives during arming', async () => {
it('acknowledges receipt separately before cancellable database work terminates', async () => {
process.env[WORKER_PROFILING_ENV] = '1';
const armGate = createArmGate();
const port = createParentPortHarness<
DbWorkerIncomingMessage,
@@ -225,6 +152,24 @@ describe('worker cancellation while performance capture arms', () => {
type: 'cancel',
operationId: 'database-operation-1',
});
const receipt = port.postMessage.mock.calls.find(
([message]) => message.type === 'performance-cancel-received'
)?.[0];
expect(receipt).toEqual({
type: 'performance-cancel-received',
operationId: 'database-operation-1',
requestId: 'request-1',
epochMs: expect.any(Number),
});
expect(
port.postMessage.mock.calls.some(
([message]) =>
message.type === 'event' &&
message.event.status === 'cancelled'
)
).toBe(false);
armGate.release();
await requestPromise;
@@ -249,6 +194,79 @@ describe('worker cancellation while performance capture arms', () => {
);
});
it('does not acknowledge receipt when request capture is unavailable', async () => {
process.env[WORKER_PROFILING_ENV] = '1';
const armGate = createArmGate();
const port = createParentPortHarness<
DbWorkerIncomingMessage,
DbWorkerMessage
>();
const saveContent = jest.fn(
async (
_db: unknown,
_playlistId: string,
_streams: unknown[],
_type: string,
control: { checkpoint: () => Promise<void> }
) => {
await control.checkpoint();
return { count: 0 };
}
);
mockPerformanceCapture(armGate, undefined, null);
jest.doMock('worker_threads', () => ({
parentPort: port.parentPort,
}));
jest.doMock('./database.worker-connection', () => ({
closeWorkerDatabase: jest.fn(),
getWorkerDatabase: jest.fn().mockResolvedValue({}),
}));
jest.doMock('../database/operations/content.operations', () => ({
saveContent,
}));
jest.spyOn(console, 'error').mockImplementation(() => undefined);
await import('./database.worker');
port.postMessage.mockClear();
const handleMessage = port.getMessageHandler();
const requestPromise = handleMessage({
type: 'request',
operation: 'DB_SAVE_CONTENT',
payload: {
operationId: 'database-operation-no-capture',
playlistId: 'playlist-1',
streams: [],
type: 'movie',
},
requestId: 'request-no-capture',
});
await handleMessage({
type: 'cancel',
operationId: 'database-operation-no-capture',
});
expect(
port.postMessage.mock.calls.some(
([message]) => message.type === 'performance-cancel-received'
)
).toBe(false);
armGate.release();
await requestPromise;
expect(port.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
operationId: 'database-operation-no-capture',
status: 'cancelled',
}),
requestId: 'request-no-capture',
type: 'event',
})
);
});
it('does not make an explicitly non-cancellable database operation cancellable during arming', async () => {
const armGate = createArmGate();
const port = createParentPortHarness<
@@ -317,6 +335,7 @@ describe('worker cancellation while performance capture arms', () => {
describe('database worker performance control messages', () => {
afterEach(() => {
restoreWorkerProfilingEnvironment();
jest.restoreAllMocks();
jest.resetModules();
});
@@ -0,0 +1,94 @@
type WorkerMessageHandler<TMessage> = (
message: TMessage
) => Promise<void> | void;
export interface ArmGate {
readonly promise: Promise<void>;
readonly release: () => void;
}
export const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING';
const originalWorkerProfilingValue = process.env[WORKER_PROFILING_ENV];
export function restoreWorkerProfilingEnvironment(): void {
if (originalWorkerProfilingValue === undefined) {
delete process.env[WORKER_PROFILING_ENV];
} else {
process.env[WORKER_PROFILING_ENV] = originalWorkerProfilingValue;
}
}
export function createArmGate(): ArmGate {
let release = (): void => undefined;
const promise = new Promise<void>((resolvePromise) => {
release = resolvePromise;
});
return { promise, release };
}
export function createParentPortHarness<TIncoming, TOutgoing>() {
let messageHandler: WorkerMessageHandler<TIncoming> | null = null;
const postMessage = jest.fn<void, [TOutgoing]>();
const parentPort = {
on: jest.fn(
(event: string, handler: WorkerMessageHandler<TIncoming>): void => {
if (event === 'message') {
messageHandler = handler;
}
}
),
postMessage,
};
return {
getMessageHandler: (): WorkerMessageHandler<TIncoming> => {
if (!messageHandler) {
throw new Error('Worker message handler was not registered');
}
return messageHandler;
},
parentPort,
postMessage,
};
}
export function mockPerformanceCapture(
armGate: ArmGate,
failureGates?: {
readonly observed: ArmGate;
readonly profilingFinished: ArmGate;
},
capture: unknown = {}
): void {
jest.doMock('./worker-performance-capture', () => ({
armWorkerPerformanceCapture: jest.fn(() => armGate.promise),
executeWithWorkerPerformanceCapture: jest.fn(
async (_capture: unknown, execute: () => Promise<unknown>) => {
try {
return {
error: null,
performance: undefined,
result: await execute(),
success: true,
};
} catch (error) {
failureGates?.observed.release();
await failureGates?.profilingFinished.promise;
return {
error,
performance: undefined,
result: undefined,
success: false,
};
}
}
),
registerDatabaseWorkerPerformanceCapture: jest.fn(),
releaseDatabaseWorkerPerformanceCapture: jest.fn(),
stampWorkerPerformanceResponsePostedEpoch: jest.fn(
(_capture: unknown, performance: unknown) => performance
),
startWorkerPerformanceCapture: jest.fn(() => capture),
}));
}
@@ -1,6 +1,6 @@
import type { EventLoopUtilization } from 'node:perf_hooks';
import type {
AppPlaylistPerformancePhase,
DatabaseWorkerPerformancePhase,
PerformancePhaseEvent,
} from '@iptvnator/shared/interfaces';
@@ -50,7 +50,7 @@ export interface WorkerPerformanceCaptureResult {
eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null;
histogramFlushedEpochMs: number | null;
invalidReason: WorkerPerformanceInvalidReason | null;
phaseEvents?: readonly PerformancePhaseEvent<AppPlaylistPerformancePhase>[];
phaseEvents?: readonly PerformancePhaseEvent<DatabaseWorkerPerformancePhase>[];
requestReceivedEpochMs: number;
responsePostedEpochMs?: number;
threadCpuSystemMicros: number | null;
@@ -91,7 +91,7 @@ export interface WorkerPerformanceCapture {
histogramDisabled: boolean;
histogramFlushedEpochMs: number | null;
invalidReason: WorkerPerformanceInvalidReason | null;
phaseEvents: PerformancePhaseEvent<AppPlaylistPerformancePhase>[];
phaseEvents: PerformancePhaseEvent<DatabaseWorkerPerformancePhase>[];
requestReceivedEpochMs: number;
requestId: string | null;
runtime: WorkerPerformanceCaptureRuntime;
@@ -30,9 +30,12 @@ describe('worker performance integration order', () => {
expect(
handler.indexOf('executeWithWorkerPerformanceCapture')
).toBeLessThan(handler.indexOf('console.error'));
expect(
handler.indexOf('executeWithWorkerPerformanceCapture')
).toBeLessThan(handler.indexOf('postMessage'));
const executionIndex = handler.indexOf(
'executeWithWorkerPerformanceCapture'
);
expect(executionIndex).toBeLessThan(
handler.indexOf('postMessage', executionIndex)
);
expect(
handler.match(/stampWorkerPerformanceResponsePostedEpoch/g)
).toHaveLength(2);
@@ -0,0 +1,76 @@
import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces';
import {
finishWorkerPerformanceCapture,
startWorkerPerformanceCapture,
} from './worker-performance-capture';
import { createFakeRuntime } from './worker-performance-capture.test-harness';
import { createWorkerPerformancePhaseAdapter } from './worker-performance-phase';
describe('database operation worker performance adapter', () => {
it('returns no adapter when profiling is disabled', () => {
expect(createWorkerPerformancePhaseAdapter(null)).toBeUndefined();
});
it('delegates Xtream phases into the request capture with count-only metadata', async () => {
const capture = startWorkerPerformanceCapture({
enabled: true,
requestId: 'xtream-db-request',
runtime: createFakeRuntime().runtime,
});
const adapter = createWorkerPerformancePhaseAdapter(capture);
const rows = [{ id: 1 }, { id: 2 }];
await expect(
adapter?.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ,
() => Promise.resolve(rows),
(result) => ({ itemCount: result.length })
)
).resolves.toBe(rows);
const result = await finishWorkerPerformanceCapture(capture);
expect(result?.phaseEvents).toEqual([
expect.objectContaining({
boundary: 'start',
metadata: undefined,
phase: 'sqlite.categories.read',
requestId: 'xtream-db-request',
}),
expect.objectContaining({
boundary: 'end',
metadata: { itemCount: 2 },
phase: 'sqlite.categories.read',
requestId: 'xtream-db-request',
}),
]);
});
it('closes rejected phases without metadata and preserves error identity', async () => {
const capture = startWorkerPerformanceCapture({
enabled: true,
requestId: 'xtream-db-error',
runtime: createFakeRuntime().runtime,
});
const adapter = createWorkerPerformancePhaseAdapter(capture);
const businessError = new Error('read failed');
await expect(
adapter?.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ,
() => Promise.reject(businessError),
() => ({ itemCount: 99 })
)
).rejects.toBe(businessError);
const result = await finishWorkerPerformanceCapture(capture);
expect(
result?.phaseEvents?.map(({ boundary, metadata }) => ({
boundary,
metadata,
}))
).toEqual([
{ boundary: 'start', metadata: undefined },
{ boundary: 'end', metadata: undefined },
]);
});
});
@@ -1,15 +1,16 @@
import {
PERFORMANCE_PHASE_BOUNDARY,
type AppPlaylistPerformancePhase,
type DatabaseWorkerPerformancePhase,
type PerformancePhaseEvent,
type PerformancePhaseMetadata,
} from '@iptvnator/shared/interfaces';
import type { DatabaseOperationPerformancePhaseCapture } from '../database/operations/performance-phase-capture';
import type { WorkerPerformanceCapture } from './worker-performance-capture.model';
import { safeReadEpochMs } from './worker-performance-capture.runtime';
export function captureWorkerPerformancePhase<TResult>(
capture: WorkerPerformanceCapture | null,
phase: AppPlaylistPerformancePhase,
phase: DatabaseWorkerPerformancePhase,
execute: () => TResult,
metadata?: (result: TResult) => PerformancePhaseMetadata
): TResult {
@@ -55,7 +56,7 @@ export function captureWorkerPerformancePhase<TResult>(
export function captureWorkerPerformancePhaseAsync<TResult>(
capture: WorkerPerformanceCapture | null,
phase: AppPlaylistPerformancePhase,
phase: DatabaseWorkerPerformancePhase,
execute: () => Promise<TResult>,
metadata?: (result: TResult) => PerformancePhaseMetadata
): Promise<TResult> {
@@ -70,7 +71,7 @@ export function captureWorkerPerformancePhaseAsync<TResult>(
async function captureAsync<TResult>(
capture: WorkerPerformanceCapture,
requestId: string,
phase: AppPlaylistPerformancePhase,
phase: DatabaseWorkerPerformancePhase,
execute: () => Promise<TResult>,
metadata?: (result: TResult) => PerformancePhaseMetadata
): Promise<TResult> {
@@ -91,9 +92,7 @@ async function captureAsync<TResult>(
boundary: PERFORMANCE_PHASE_BOUNDARY.END,
durationMs: safeDuration(endMonotonicMs - startMonotonicMs),
epochMs: safeReadEpochMs(capture.runtime),
metadata: sanitizeMetadata(
safelyCreateMetadata(metadata, result)
),
metadata: sanitizeMetadata(safelyCreateMetadata(metadata, result)),
phase,
requestId,
});
@@ -162,7 +161,7 @@ function isCount(value: unknown): value is number {
function appendPhaseEvent(
capture: WorkerPerformanceCapture,
event: PerformancePhaseEvent<AppPlaylistPerformancePhase>
event: PerformancePhaseEvent<DatabaseWorkerPerformancePhase>
): void {
try {
capture.phaseEvents.push(event);
@@ -170,3 +169,23 @@ function appendPhaseEvent(
// Phase instrumentation is deliberately fail-neutral.
}
}
export function createWorkerPerformancePhaseAdapter(
capture: WorkerPerformanceCapture | null
): DatabaseOperationPerformancePhaseCapture | undefined {
if (!capture?.requestId) {
return undefined;
}
return {
captureAsync: (phase, execute, metadata) =>
captureWorkerPerformancePhaseAsync(
capture,
phase,
execute,
metadata
),
captureSync: (phase, execute, metadata) =>
captureWorkerPerformancePhase(capture, phase, execute, metadata),
};
}
@@ -0,0 +1,58 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
function readCase(source: string, operation: string, nextOperation: string) {
return source.slice(
source.indexOf(`case '${operation}':`),
source.indexOf(`case '${nextOperation}':`)
);
}
describe('Xtream database performance phase wiring', () => {
const source = readFileSync(
join(
process.cwd(),
'apps/electron-backend/src/app/workers/database.worker.ts'
),
'utf8'
);
const executeRequestSource = source.slice(
source.indexOf('async function executeRequest')
);
it.each([
['DB_GET_CATEGORIES', 'DB_SAVE_CATEGORIES', 'getCategories'],
['DB_SAVE_CATEGORIES', 'DB_GET_ALL_CATEGORIES', 'saveCategories'],
['DB_GET_CONTENT', 'DB_GET_GLOBAL_RECENTLY_ADDED', 'getContent'],
['DB_SAVE_CONTENT', 'DB_CLEAR_XTREAM_IMPORT_CACHE', 'saveContent'],
[
'DB_CLEAR_XTREAM_IMPORT_CACHE',
'DB_GET_CONTENT_BY_XTREAM_ID',
'clearXtreamImportCache',
],
['DB_SEARCH_CONTENT', 'DB_GLOBAL_SEARCH', 'searchContent'],
['DB_DELETE_PLAYLIST', 'DB_GET_APP_STATE', 'deletePlaylist'],
[
'DB_DELETE_XTREAM_CONTENT',
'DB_RESTORE_XTREAM_USER_DATA',
'deleteXtreamContent',
],
])('passes the optional adapter through %s', (operation, next, call) => {
const branch = readCase(executeRequestSource, operation, next);
expect(branch).toContain('createWorkerPerformancePhaseAdapter');
expect(branch).toContain('performanceCapture');
expect(branch).toContain(call);
});
it('leaves mixed global search uninstrumented', () => {
const branch = readCase(
executeRequestSource,
'DB_GLOBAL_SEARCH',
'DB_CREATE_PLAYLIST'
);
expect(branch).not.toContain('createWorkerPerformancePhaseAdapter');
expect(branch).not.toContain('performanceCapture');
});
});