Files
iptvnator/libs/shared/database/src/lib/testing/connection-upgrade.ts
T
1d9a563d1a test(performance): count startup phases and SQL statements for the J1 launch journey (#1715)
* test(performance): count startup phases and SQL statements for the J1 launch journey

Implements plan item A2. With IPTVNATOR_PERF_CAPTURE=1 the main process
keeps named counters and registers a main-only performance:read-counters
IPC handler; without the flag nothing is counted and the handler does not
exist.

- debug-trace.ts owns the registry; traceStartupPhase replaces the
  trace('startup', ...) sites and counts main.startupPhases.
- The database worker counts executed statements through better-sqlite3's
  Statement prototype (the verbose callback expands every statement and
  made bulk inserts 2-4x slower) and posts the count over its message
  port, flushed before every other worker message. The main-thread shared
  connection is counted through a new connection observer in the shared
  database library.
- The first main window freezes main.modulesRegisteredBeforeWindow at
  creation and main.sqlStatementsBeforeReadyToShow at ready-to-show.
- The journey gate drops the ready-to-show that Electron emits for the
  about:blank detour, so the app sees the real document's first paint,
  and taps the counters handler; the J1 record reads both counters after
  the renderer probe completes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(database): require one SQL statement per exec during initialization

The performance capture counts one exec call as one statement, because
SQL cannot be split reliably in the counter (trigger bodies contain
semicolons). The historical-upgrade driver now wraps exec on every
connection initDatabase opens and fails on a batch, so that counting
assumption holds for the fresh profile and all historical schemas.
Documents the definition in the counter and the architecture docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(performance): count SQL statements only for the launch journey

Codex review: the M3U import, refresh-cancellation and Xtream benchmarks
also run with IPTVNATOR_PERF_CAPTURE=1, so the statement hook wrapped
every row of their bulk inserts and changed what they measure.

SQL counting now also needs IPTVNATOR_PERF_COUNT_SQL=1, which only the
launch journey sets; a harness test fails if another source sets it.
Startup phases, the window snapshot and the read handler stay on the
capture flag. Without SQL counting no ready-to-show listener is attached,
so a zero is never reported for statements nobody counted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:01:26 +02:00

199 lines
7.4 KiB
TypeScript

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import Database from 'better-sqlite3';
import { getTableConfig } from 'drizzle-orm/sqlite-core';
import { closeDatabase, getDatabasePath, initDatabase } from '../connection';
import { setDatabaseConnectionObserver } from '../connection-observer';
import * as currentSchema from '../schema';
const tables = [
'playlists',
'categories',
'content',
'favorites',
'recently_viewed',
'playback_positions',
'downloads',
] as const;
function seed(sqlite: Database.Database) {
sqlite.exec(`
INSERT INTO playlists (id, name, type, serverUrl, username, password)
VALUES ('saved', 'Saved source', 'xtream', 'https://source.invalid',
'synthetic-user', 'synthetic-password');
INSERT INTO categories (id, playlist_id, name, type, xtream_id, hidden)
VALUES (1, 'saved', 'Movies', 'movies', 42, 1);
INSERT INTO content (id, category_id, title, xtream_id, type, added)
VALUES (1, 1, 'Saved movie', 7, 'movie', '1700000000');
INSERT INTO favorites (content_id, playlist_id) VALUES (1, 'saved');
INSERT INTO recently_viewed (content_id, playlist_id) VALUES (1, 'saved');
INSERT INTO playback_positions
(playlist_id, content_xtream_id, content_type, position_seconds, duration_seconds)
VALUES ('saved', 7, 'vod', 123, 1800);
INSERT INTO downloads (playlist_id, xtream_id, content_type, title, url, status)
VALUES ('saved', 7, 'vod', 'Saved movie', 'https://source.invalid/movie', 'completed');
`);
const columns = sqlite.pragma('table_info(content)') as { name: string }[];
if (columns.some(({ name }) => name === 'epg_channel_id')) {
sqlite.exec("UPDATE content SET epg_channel_id = 'retained-epg-id'");
}
}
/**
* The performance capture counts one `exec` call as one SQL statement
* (apps/electron-backend/src/app/workers/database-worker-sql-statement-count.ts),
* so initialization must pass exactly one statement per `exec`. better-sqlite3
* refuses to prepare a string with a second statement; other prepare errors
* are left to `exec` itself (for example an idempotent ALTER TABLE).
*/
function requireSingleStatementExec(): string[] {
const batches: string[] = [];
setDatabaseConnectionObserver((connection) => {
const exec = connection.exec;
connection.exec = function singleStatementExec(sql: string) {
try {
connection.prepare(sql);
} catch (error) {
if (error instanceof RangeError) {
batches.push(sql.replace(/\s+/g, ' ').slice(0, 120));
}
}
return exec.call(this, sql);
};
});
return batches;
}
function snapshot(sqlite: Database.Database) {
return tables.map((table) => {
const columns = sqlite.pragma(`table_info(${table})`) as {
name: string;
}[];
const query = `SELECT ${columns.map(({ name }) => `"${name}"`).join(', ')} FROM ${table} ORDER BY id`;
return { query, rows: sqlite.prepare(query).all() };
});
}
function epgIndex(sqlite: Database.Database) {
return sqlite
.prepare(
"SELECT sql, rootpage FROM sqlite_master WHERE name = 'idx_content_epg_channel'"
)
.get();
}
function verifyCurrentSchema(sqlite: Database.Database) {
for (const table of Object.values(currentSchema)) {
const { name, columns, indexes } = getTableConfig(table);
const actualColumns = sqlite.pragma(`table_info(${name})`) as {
name: string;
type: string;
}[];
assert(actualColumns.length > 0, `Missing current table: ${name}`);
for (const column of columns) {
const actual = actualColumns.find(
(item) => item.name === column.name
);
assert(actual, `Missing current column: ${name}.${column.name}`);
assert.equal(
actual.type.toLowerCase(),
column.getSQLType().toLowerCase()
);
}
const actualIndexes = sqlite.pragma(`index_list(${name})`) as {
name: string;
unique: number;
}[];
for (const { config } of indexes) {
const actual = actualIndexes.find(
(item) => item.name === config.name
);
assert(actual, `Missing current index: ${config.name}`);
assert.equal(actual.unique, Number(config.unique), config.name);
}
}
}
async function main() {
const fixture = process.argv[2];
assert(
process.env['IPTVNATOR_E2E_DATA_DIR'],
'Never open the user database'
);
const warnings: unknown[][] = [];
console.warn = (...args) => warnings.push(args);
console.log = () => undefined;
const databasePath = getDatabasePath();
const execBatches = requireSingleStatementExec();
if (fixture === 'fresh') {
await initDatabase();
closeDatabase();
}
const legacy = new Database(databasePath);
legacy.pragma('foreign_keys = ON');
if (fixture !== 'fresh') legacy.exec(readFileSync(fixture, 'utf8'));
seed(legacy);
const before = snapshot(legacy);
const originalIndex = epgIndex(legacy);
legacy.close();
let firstSchema: unknown;
for (let startup = 0; startup < 2; startup++) {
try {
await initDatabase();
const sqlite = new Database(databasePath, { readonly: true });
try {
verifyCurrentSchema(sqlite);
for (const { query, rows } of before) {
assert.deepEqual(sqlite.prepare(query).all(), rows, query);
}
assert.deepEqual(sqlite.pragma('foreign_key_check'), []);
assert.deepEqual(sqlite.pragma('integrity_check'), [
{ integrity_check: 'ok' },
]);
const index = epgIndex(sqlite);
assert(index, 'EPG index must exist after initialization');
assert.deepEqual(
(
sqlite.pragma(
'index_info(idx_content_epg_channel)'
) as { name: string }[]
).map(({ name }) => name),
['epg_channel_id']
);
if (originalIndex) assert.deepEqual(index, originalIndex);
// closeDatabase() may add SQLite's own planner statistics via
// PRAGMA optimize; compare only application schema objects.
const schema = sqlite
.prepare(
"SELECT type, name, sql FROM sqlite_master WHERE name NOT GLOB 'sqlite_*' ORDER BY type, name"
)
.all();
if (startup === 0) firstSchema = schema;
else assert.deepEqual(schema, firstSchema);
} finally {
sqlite.close();
}
} finally {
closeDatabase();
}
}
assert.deepEqual(
warnings,
[],
'Initialization must not silently skip failed migrations'
);
assert.deepEqual(
execBatches,
[],
'Initialization must pass one SQL statement per exec call'
);
process.stdout.write('upgrade verified');
}
main().catch((error: unknown) => {
console.error(error);
process.exitCode = 1;
});