444 lines
13 KiB
TypeScript
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;
|
|
};
|