Files
lnreader/src/database/db.ts
T
2026-09-07 22:25:31 +05:30

444 lines
13 KiB
TypeScript

/* eslint-disable no-console */
import { drizzle } from 'drizzle-orm/op-sqlite';
import { schema } from './schema';
import { Logger } from 'drizzle-orm';
import { migrate } from 'drizzle-orm/op-sqlite/migrator';
import migrations from '../../drizzle/migrations';
import { createDbManager } from './manager/manager';
import { open } from '@op-engineering/op-sqlite';
import {
createCategoryDefaultQuery,
repairDefaultCategoryNameQuery,
} from './queryStrings/populate';
import {
createCategoryTriggerQuery,
createNovelTriggerQueryDelete,
createNovelTriggerQueryInsert,
createNovelTriggerQueryUpdate,
} from './queryStrings/triggers';
import { useEffect, useReducer } from 'react';
import { getErrorChainMessages } from '@utils/error';
class MyLogger implements Logger {
logQuery(_query: string, _params: unknown[]): void {
//console.trace('DB Query: ', { query, params });
}
}
const DB_NAME = 'lnreader.db';
const _db = open({ name: DB_NAME, location: '../files/SQLite' });
const INITIAL_MIGRATION_NAME = '20251222152612_past_mandrill';
const INITIAL_MIGRATION_CREATED_AT = 1766417172000;
const SCANLATOR_MIGRATION_NAME = '20260612232322_normal_saracen';
const SCANLATOR_MIGRATION_CREATED_AT = 1781306602000;
const TIME_SPENT_MIGRATION_NAME = '20260719143427_long_moondragon';
const TIME_SPENT_MIGRATION_CREATED_AT = 1784471667000;
const CHAPTER_COLUMN_MIGRATIONS = [
{
columnName: 'scanlator',
columnDefinition: 'text',
migrationName: SCANLATOR_MIGRATION_NAME,
createdAt: SCANLATOR_MIGRATION_CREATED_AT,
},
{
columnName: 'timeSpent',
columnDefinition: 'integer DEFAULT 0',
migrationName: TIME_SPENT_MIGRATION_NAME,
createdAt: TIME_SPENT_MIGRATION_CREATED_AT,
},
] as const;
/**
* Raw SQLite database instance
* @deprecated Use `drizzleDb` for new code
*/
export const db = _db;
/**
* Drizzle ORM database instance with type-safe query builder
* Use this for all new database operations
*/
export const drizzleDb = drizzle(_db, {
schema,
logger: __DEV__ ? new MyLogger() : false,
});
export const dbManager = createDbManager(drizzleDb);
type SqlExecutor = {
executeSync: (
sql: string,
params?: Parameters<typeof _db.executeSync>[1],
) => void;
};
type MigrationExecutor = {
executeRawSync: (sql: string) => unknown[][];
executeSync: (sql: string) => unknown;
};
/**
* Recovers the one interruption point where the previous repair migration
* could have dropped Novel before renaming its populated replacement.
*/
export const repairInterruptedNovelMigration = (
executor: MigrationExecutor,
) => {
const tableNames = new Set(
executor
.executeRawSync("SELECT name FROM sqlite_master WHERE type = 'table';")
.map(row => row[0]),
);
const hasNovel = tableNames.has('Novel');
const hasNovelSnapshot = tableNames.has('__migration_Novel');
const hasNewNovel = tableNames.has('__new_Novel');
const hasMigrationState =
hasNovelSnapshot ||
hasNewNovel ||
tableNames.has('__migration_Chapter') ||
tableNames.has('__migration_NovelCategory');
if (!hasMigrationState || hasNovel || hasNovelSnapshot) {
return;
}
if (!hasNewNovel) {
throw new Error(
'Cannot recover interrupted Novel migration: no source table remains',
);
}
executor.executeSync("ALTER TABLE '__new_Novel' RENAME TO 'Novel';");
};
/**
* Brings a retained Chapter snapshot up to the current schema before an
* interrupted migration is retried. Older snapshots can predate columns that
* were added to Chapter, so restoring them with the current migration would
* otherwise fail with a column-count mismatch.
*/
export const repairChapterMigrationSnapshot = (executor: MigrationExecutor) => {
const snapshotColumns = executor.executeRawSync(
'PRAGMA table_info(__migration_Chapter);',
);
if (snapshotColumns.length === 0) {
return;
}
const snapshotColumnNames = new Set(snapshotColumns.map(row => row[1]));
for (const migration of CHAPTER_COLUMN_MIGRATIONS) {
if (snapshotColumnNames.has(migration.columnName)) {
continue;
}
executor.executeSync(
`ALTER TABLE '__migration_Chapter' ADD COLUMN '${migration.columnName}' ${migration.columnDefinition};`,
);
}
};
/**
* Backfills novels into a stale `__migration_Novel` snapshot so an interrupted
* migration can be retried without losing novels written while it was down.
*
* The migration snapshots are created with `IF NOT EXISTS`. A build that ran
* the migration outside a transaction and died part-way leaves a stale
* `__migration_Novel` on disk while `Novel`/`Chapter`/`NovelCategory` keep
* accepting writes; later launches re-run the migration, rebuild `Novel` from
* that stale snapshot, and the Chapter/NovelCategory copy-back then violates
* the `FOREIGN KEY (novelId)` that pre-drizzle databases still carry on both
* tables — failing on every launch. Novels written after the snapshot went
* stale are backfilled from the live tables (or, when a crashed attempt
* already destroyed them, their references are pruned) so the migration
* completes and the surviving data is preserved.
*/
export const repairInterruptedNovelSnapshot = (executor: MigrationExecutor) => {
const tableNames = new Set(
executor
.executeRawSync("SELECT name FROM sqlite_master WHERE type = 'table';")
.map(row => row[0]),
);
if (!tableNames.has('Novel') || !tableNames.has('__migration_Novel')) {
return;
}
// What the copy-back will restore: the live tables when a failed attempt was
// rolled back, the retained snapshots when a partial attempt was persisted.
const referencedNovelIds = new Set(
[
'Chapter',
'NovelCategory',
'__migration_Chapter',
'__migration_NovelCategory',
]
.filter(tableName => tableNames.has(tableName))
.flatMap(tableName =>
executor
.executeRawSync(`SELECT DISTINCT novelId FROM \`${tableName}\`;`)
.map(row => row[0]),
),
);
if (referencedNovelIds.size === 0) {
return;
}
const snapshotNovelIds = new Set(
executor
.executeRawSync('SELECT id FROM __migration_Novel;')
.map(row => row[0]),
);
const missingNovelIds = [...referencedNovelIds].filter(
novelId => novelId !== null && !snapshotNovelIds.has(novelId),
);
if (missingNovelIds.length > 0) {
// The stale snapshot can predate the migration's Novel rename, so its
// columns may differ from the live table (e.g. pre-drizzle 13 vs 18).
// Insert through the intersection of both column sets.
const snapshotColumns = executor
.executeRawSync('PRAGMA table_info(__migration_Novel);')
.map(row => row[1]);
const novelColumns = new Set(
executor.executeRawSync('PRAGMA table_info(Novel);').map(row => row[1]),
);
const sharedColumns = snapshotColumns.filter(column =>
novelColumns.has(column),
);
if (sharedColumns.length > 0) {
const columnList = sharedColumns
.map(column => `\`${column}\``)
.join(', ');
executor.executeSync(
`INSERT INTO __migration_Novel (${columnList}) SELECT ${columnList} FROM Novel WHERE id IN (${missingNovelIds.join(
', ',
)});`,
);
}
}
// Novels referenced only by the retained snapshots are unrecoverable (a
// crashed attempt already dropped the live table). Prune their rows so the
// copy-back cannot fail the foreign key on every launch.
for (const tableName of [
'__migration_Chapter',
'__migration_NovelCategory',
]) {
if (!tableNames.has(tableName)) {
continue;
}
executor.executeSync(
`DELETE FROM \`${tableName}\` WHERE novelId NOT IN (SELECT id FROM __migration_Novel);`,
);
}
};
/**
* Repairs migration metadata created by older Drizzle versions and interrupted
* migrations. SQLite cannot add a column conditionally, so an existing column
* must be recorded before the migrator attempts to add it again.
*/
export const repairMigrationHistory = (executor: MigrationExecutor) => {
const migrationColumns = executor.executeRawSync(
'PRAGMA table_info(__drizzle_migrations);',
);
if (migrationColumns.length === 0) {
return;
}
const columnNames = new Set(migrationColumns.map(row => row[1]));
if (!columnNames.has('name')) {
executor.executeSync(
"ALTER TABLE '__drizzle_migrations' ADD COLUMN 'name' text;",
);
}
if (!columnNames.has('applied_at')) {
executor.executeSync(
"ALTER TABLE '__drizzle_migrations' ADD COLUMN 'applied_at' text;",
);
}
executor.executeSync(`
UPDATE __drizzle_migrations
SET name = '${INITIAL_MIGRATION_NAME}'
WHERE name IS NULL
AND created_at = ${INITIAL_MIGRATION_CREATED_AT};
`);
const chapterColumns = executor.executeRawSync('PRAGMA table_info(Chapter);');
const chapterColumnNames = new Set(chapterColumns.map(row => row[1]));
for (const migration of CHAPTER_COLUMN_MIGRATIONS) {
if (!chapterColumnNames.has(migration.columnName)) {
continue;
}
executor.executeSync(`
INSERT INTO __drizzle_migrations
(hash, created_at, name, applied_at)
SELECT '', ${migration.createdAt},
'${migration.migrationName}', datetime('now')
WHERE NOT EXISTS (
SELECT 1 FROM __drizzle_migrations
WHERE name = '${migration.migrationName}'
);
`);
}
};
/**
* Drizzle beta 20 does not currently read op-sqlite's array-shaped query
* results when deciding which migrations are pending. Filter them explicitly
* until the driver handles the current op-sqlite result shape.
*/
export const getPendingMigrations = (executor: MigrationExecutor) => {
const migrationColumns = executor.executeRawSync(
'PRAGMA table_info(__drizzle_migrations);',
);
if (migrationColumns.length === 0) {
return migrations;
}
const appliedMigrations = new Set(
executor
.executeRawSync(
'SELECT name FROM __drizzle_migrations WHERE name IS NOT NULL;',
)
.map(row => row[0]),
);
return {
migrations: Object.fromEntries(
Object.entries(migrations.migrations).filter(
([name]) => !appliedMigrations.has(name),
),
),
};
};
const setPragmas = (executor: SqlExecutor) => {
console.log('Setting database Pragmas');
const queries = [
'PRAGMA journal_mode = WAL',
'PRAGMA synchronous = NORMAL',
'PRAGMA temp_store = MEMORY',
'PRAGMA busy_timeout = 5000',
'PRAGMA cache_size = 10000',
'PRAGMA foreign_keys = ON',
];
queries.forEach(query => executor.executeSync(query));
};
const populateDatabase = (executor: SqlExecutor) => {
console.log('Populating database');
executor.executeSync(createCategoryDefaultQuery);
executor.executeSync(repairDefaultCategoryNameQuery);
};
const createDbTriggers = (executor: SqlExecutor) => {
console.log('Creating database triggers');
executor.executeSync('DROP TRIGGER IF EXISTS update_novel_stats');
executor.executeSync('DROP TRIGGER IF EXISTS update_novel_stats_on_update');
executor.executeSync('DROP TRIGGER IF EXISTS update_novel_stats_on_delete');
executor.executeSync('DROP TRIGGER IF EXISTS add_category');
executor.executeSync(createCategoryTriggerQuery);
executor.executeSync(createNovelTriggerQueryDelete);
executor.executeSync(createNovelTriggerQueryInsert);
executor.executeSync(createNovelTriggerQueryUpdate);
};
export const runDatabaseBootstrap = (executor: SqlExecutor) => {
createDbTriggers(executor);
populateDatabase(executor);
};
let initialization: Promise<void> | undefined;
export const initializeDatabase = () => {
if (!initialization) {
setPragmas(_db);
repairInterruptedNovelMigration(_db);
repairInterruptedNovelSnapshot(_db);
repairChapterMigrationSnapshot(_db);
repairMigrationHistory(_db);
initialization = migrate(drizzleDb, getPendingMigrations(_db))
.then(() => {
runDatabaseBootstrap(_db);
})
.catch((error: Error) => {
// DrizzleQueryError keeps the native SQLite message (e.g. "FOREIGN KEY
// constraint failed") on `cause`; without it the reported trace ends at
// "params:" and the real reason is unrecoverable.
console.error(
'Database initialization failed:',
getErrorChainMessages(error).join(' Caused by: '),
error.stack,
);
throw error;
});
}
return initialization;
};
type InitDbState = {
success?: boolean;
error?: Error;
};
const initialState = {
success: false,
error: undefined,
};
const fetchReducer = (
state$1: InitDbState,
action:
| {
type: 'migrating' | 'migrated';
payload?: boolean | undefined;
}
| {
type: 'error';
payload: Error;
},
) => {
switch (action.type) {
case 'migrating':
return { ...initialState };
case 'migrated':
return {
...initialState,
success: action.payload,
};
case 'error':
return {
...initialState,
error: action.payload,
};
default:
return state$1;
}
};
export const useInitDatabase = () => {
const [state, dispatch] = useReducer(fetchReducer, initialState);
useEffect(() => {
dispatch({ type: 'migrating' });
initializeDatabase()
.then(() => {
dispatch({
type: 'migrated',
payload: true,
});
})
.catch((error: Error) => {
dispatch({
type: 'error',
payload: error,
});
});
}, []);
return state;
};