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; });