mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
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:
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;
|
||||
}
|
||||
}
|
||||
+110
@@ -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(
|
||||
|
||||
+68
@@ -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;
|
||||
}
|
||||
+165
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user