fix(api): migrations idempotentes pour bases existantes (metadata/automation)
Les colonnes metadata_source_config et automation_settings sont désormais ajoutées par ALTER TABLE conditionnels sur les bases déjà initialisées, l'index books_isbn13_idx est créé via le chemin de migration, et un test couvre la base de données existante. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
109
apps/api/src/database/database.service.test.ts
Normal file
109
apps/api/src/database/database.service.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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<string> {
|
||||
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, "''")}'`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user