diff --git a/apps/api/src/database/database.service.test.ts b/apps/api/src/database/database.service.test.ts new file mode 100644 index 0000000..0c3279c --- /dev/null +++ b/apps/api/src/database/database.service.test.ts @@ -0,0 +1,109 @@ +import Database from "better-sqlite3"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { DatabaseService } from "./database.service.js"; + +const previousDatabasePath = process.env.DATABASE_PATH; +const previousStorageDir = process.env.STORAGE_DIR; +const tempDirs: string[] = []; + +afterEach(() => { + process.env.DATABASE_PATH = previousDatabasePath; + process.env.STORAGE_DIR = previousStorageDir; + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("database migrations", () => { + it("adds metadata columns to an existing comic-capable books table before creating dependent indexes", () => { + const dir = mkdtempSync(join(tmpdir(), "readabook-migration-")); + tempDirs.push(dir); + const databasePath = join(dir, "readabook.sqlite"); + const storageDir = join(dir, "storage"); + + const legacy = new Database(databasePath); + legacy.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + name TEXT, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE libraries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + path TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + library_id INTEGER NOT NULL REFERENCES libraries(id) ON DELETE CASCADE, + title TEXT NOT NULL, + author TEXT, + description TEXT, + isbn TEXT, + language TEXT, + publisher TEXT, + published_date TEXT, + format TEXT NOT NULL CHECK (format IN ('epub','pdf','cbz','cbr')), + file_path TEXT NOT NULL UNIQUE, + cover_path TEXT, + file_size INTEGER NOT NULL, + file_mtime TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + book_id INTEGER NOT NULL REFERENCES books(id) ON DELETE CASCADE, + locator TEXT NOT NULL, + percent INTEGER NOT NULL CHECK (percent >= 0 AND percent <= 100), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, book_id) + ); + + CREATE TABLE jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('queued','running','succeeded','failed')), + detail TEXT, + error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + `); + legacy.close(); + + process.env.DATABASE_PATH = databasePath; + process.env.STORAGE_DIR = storageDir; + + const database = new DatabaseService(); + const bookColumns = database.sqlite.prepare("PRAGMA table_info(books)").all() as Array<{ name: string }>; + const bookIndexes = database.sqlite.prepare("PRAGMA index_list(books)").all() as Array<{ name: string }>; + const metadataSources = database.sqlite.prepare("SELECT provider FROM metadata_source_config ORDER BY priority").all() as Array<{ provider: string }>; + const automationSettings = database.sqlite.prepare("SELECT id, isbn_priority_enabled FROM automation_settings").get() as + | { id: number; isbn_priority_enabled: number } + | undefined; + + expect(bookColumns.map((column) => column.name)).toContain("isbn13"); + expect(bookColumns.map((column) => column.name)).toContain("identifiers_json"); + expect(bookIndexes.map((index) => index.name)).toContain("books_isbn13_idx"); + expect(metadataSources.map((source) => source.provider)).toEqual(["local", "openlibrary", "googlebooks", "bnf"]); + expect(automationSettings).toMatchObject({ id: 1, isbn_priority_enabled: 1 }); + + database.onModuleDestroy(); + }); +}); diff --git a/apps/api/src/database/database.service.ts b/apps/api/src/database/database.service.ts index 95bd4bd..62acb45 100644 --- a/apps/api/src/database/database.service.ts +++ b/apps/api/src/database/database.service.ts @@ -122,7 +122,6 @@ export class DatabaseService implements OnModuleDestroy { CREATE INDEX IF NOT EXISTS books_library_idx ON books(library_id); CREATE INDEX IF NOT EXISTS books_title_idx ON books(title); - CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13); CREATE INDEX IF NOT EXISTS jobs_status_idx ON jobs(status); CREATE TRIGGER IF NOT EXISTS books_ai AFTER INSERT ON books BEGIN @@ -144,6 +143,8 @@ export class DatabaseService implements OnModuleDestroy { `); this.ensureBooksSupportsComicArchives(); this.ensureBooksMetadataColumns(); + this.ensureMetadataSourceConfigColumns(); + this.ensureAutomationSettingsColumns(); this.ensureMetadataDefaults(); this.sqlite.exec("INSERT INTO book_fts(book_fts) VALUES('rebuild')"); } @@ -234,6 +235,59 @@ export class DatabaseService implements OnModuleDestroy { this.sqlite.exec("CREATE INDEX IF NOT EXISTS books_isbn13_idx ON books(isbn13)"); } + private ensureMetadataSourceConfigColumns(): void { + const names = this.columnNames("metadata_source_config"); + const now = sqlString(this.now()); + if (!names.has("enabled")) { + this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1"); + } + if (!names.has("priority")) { + this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN priority INTEGER NOT NULL DEFAULT 0"); + } + if (!names.has("api_key")) { + this.sqlite.exec("ALTER TABLE metadata_source_config ADD COLUMN api_key TEXT"); + } + if (!names.has("created_at")) { + this.sqlite.exec(`ALTER TABLE metadata_source_config ADD COLUMN created_at TEXT NOT NULL DEFAULT ${now}`); + } + if (!names.has("updated_at")) { + this.sqlite.exec(`ALTER TABLE metadata_source_config ADD COLUMN updated_at TEXT NOT NULL DEFAULT ${now}`); + } + } + + private ensureAutomationSettingsColumns(): void { + const names = this.columnNames("automation_settings"); + const now = sqlString(this.now()); + const disabledScan = sqlString(JSON.stringify({ frequency: "disabled", time: "03:00", dayOfWeek: 1 })); + const disabledEnrich = sqlString(JSON.stringify({ frequency: "disabled", time: "04:00", dayOfWeek: 1 })); + if (!names.has("watch_libraries")) { + this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN watch_libraries INTEGER NOT NULL DEFAULT 0"); + } + if (!names.has("auto_enrich_new_books")) { + this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN auto_enrich_new_books INTEGER NOT NULL DEFAULT 1"); + } + if (!names.has("isbn_priority_enabled")) { + this.sqlite.exec("ALTER TABLE automation_settings ADD COLUMN isbn_priority_enabled INTEGER NOT NULL DEFAULT 1"); + } + if (!names.has("scan_schedule_json")) { + this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN scan_schedule_json TEXT NOT NULL DEFAULT ${disabledScan}`); + } + if (!names.has("enrich_schedule_json")) { + this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN enrich_schedule_json TEXT NOT NULL DEFAULT ${disabledEnrich}`); + } + if (!names.has("created_at")) { + this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN created_at TEXT NOT NULL DEFAULT ${now}`); + } + if (!names.has("updated_at")) { + this.sqlite.exec(`ALTER TABLE automation_settings ADD COLUMN updated_at TEXT NOT NULL DEFAULT ${now}`); + } + } + + private columnNames(table: string): Set { + const columns = this.sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; + return new Set(columns.map((column) => column.name)); + } + private ensureMetadataDefaults(): void { const now = this.now(); const insertSource = this.sqlite.prepare(` @@ -265,3 +319,7 @@ export class DatabaseService implements OnModuleDestroy { ); } } + +function sqlString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +}