diff --git a/drizzle/20260727081855_calm_chimera/migration.sql b/drizzle/20260727081855_calm_chimera/migration.sql index 72acd11c..7792da55 100644 --- a/drizzle/20260727081855_calm_chimera/migration.sql +++ b/drizzle/20260727081855_calm_chimera/migration.sql @@ -92,7 +92,42 @@ DELETE FROM `NovelCategory`;--> statement-breakpoint DELETE FROM `Chapter`;--> statement-breakpoint DROP TABLE IF EXISTS `Novel`;--> statement-breakpoint ALTER TABLE `__new_Novel` RENAME TO `Novel`;--> statement-breakpoint -INSERT OR REPLACE INTO `Chapter` SELECT * FROM `__migration_Chapter`;--> statement-breakpoint +INSERT OR REPLACE INTO `Chapter` ( + `id`, + `novelId`, + `path`, + `name`, + `releaseTime`, + `bookmark`, + `unread`, + `readTime`, + `isDownloaded`, + `updatedTime`, + `chapterNumber`, + `page`, + `position`, + `progress`, + `scanlator`, + `timeSpent` +) +SELECT + `id`, + `novelId`, + `path`, + `name`, + `releaseTime`, + `bookmark`, + `unread`, + `readTime`, + `isDownloaded`, + `updatedTime`, + `chapterNumber`, + `page`, + `position`, + `progress`, + `scanlator`, + `timeSpent` +FROM `__migration_Chapter`;--> statement-breakpoint INSERT OR REPLACE INTO `NovelCategory` SELECT * FROM `__migration_NovelCategory`;--> statement-breakpoint DROP TABLE `__migration_Novel`;--> statement-breakpoint DROP TABLE `__migration_Chapter`;--> statement-breakpoint diff --git a/src/database/__tests__/db.test.ts b/src/database/__tests__/db.test.ts index 6c85083f..cdf6c8de 100644 --- a/src/database/__tests__/db.test.ts +++ b/src/database/__tests__/db.test.ts @@ -6,6 +6,7 @@ import { schema } from '@database/schema'; import { getPendingMigrations, + repairChapterMigrationSnapshot, repairInterruptedNovelMigration, repairMigrationHistory, runDatabaseBootstrap, @@ -464,6 +465,126 @@ describe('production migrations', () => { } }); + it('repairs an interrupted Chapter snapshot missing recent columns', async () => { + const sqlite = open({ name: ':memory:' }); + (sqlite as any).executeAsync ??= sqlite.execute; + (sqlite as any).executeRawAsync ??= sqlite.executeRaw; + try { + createSchema(sqlite); + sqlite.executeSync(` + INSERT INTO Category (id, name, sort) + VALUES (1, 'Existing category', 1) + `); + sqlite.executeSync(` + INSERT INTO Novel (id, path, pluginId, name, inLibrary) + VALUES (1, '/interrupted', 'legacy-plugin', 'Interrupted novel', 1) + `); + sqlite.executeSync(` + INSERT INTO Chapter ( + id, + novelId, + path, + name, + unread, + isDownloaded + ) + VALUES ( + 1, + 1, + '/interrupted/chapter-1', + 'Chapter 1', + 1, + 1 + ) + `); + sqlite.executeSync(` + INSERT INTO NovelCategory (id, novelId, categoryId) + VALUES (1, 1, 1) + `); + + sqlite.executeSync( + 'CREATE TABLE __migration_Chapter AS SELECT * FROM Chapter', + ); + sqlite.executeSync('ALTER TABLE Chapter ADD scanlator text'); + sqlite.executeSync('ALTER TABLE Chapter ADD timeSpent integer DEFAULT 0'); + sqlite.executeSync( + 'CREATE TABLE __migration_Novel AS SELECT * FROM Novel', + ); + sqlite.executeSync( + 'CREATE TABLE __migration_NovelCategory AS SELECT * FROM NovelCategory', + ); + sqlite.executeSync(` + CREATE TABLE __drizzle_migrations ( + id INTEGER PRIMARY KEY, + hash text NOT NULL, + created_at numeric, + name text, + applied_at text + ) + `); + sqlite.executeSync(` + INSERT INTO __drizzle_migrations (hash, created_at, name) + VALUES ('', 1766417172000, NULL) + `); + + repairChapterMigrationSnapshot(sqlite); + repairMigrationHistory(sqlite); + + const snapshotColumns = sqlite + .executeRawSync('PRAGMA table_info(__migration_Chapter)') + .map(column => column[1]); + expect(snapshotColumns).toEqual( + expect.arrayContaining(['scanlator', 'timeSpent']), + ); + + const drizzleDb = drizzle(sqlite, { schema }); + await migrate(drizzleDb, getPendingMigrations(sqlite)); + + expect( + sqlite.executeSync('SELECT id, name, scanlator, timeSpent FROM Chapter') + .rows, + ).toEqual([ + { + id: 1, + name: 'Chapter 1', + scanlator: null, + timeSpent: 0, + }, + ]); + } finally { + sqlite.close(); + } + }); + + it('preserves snapshot columns while adding a missing timeSpent column', () => { + const sqlite = open({ name: ':memory:' }); + try { + createSchema(sqlite); + sqlite.executeSync('ALTER TABLE Chapter ADD scanlator text'); + sqlite.executeSync(` + INSERT INTO Novel (id, path, pluginId, name) + VALUES (1, '/snapshot', 'legacy-plugin', 'Snapshot novel') + `); + sqlite.executeSync(` + INSERT INTO Chapter (id, novelId, path, name, scanlator) + VALUES (1, 1, '/snapshot/chapter-1', 'Chapter 1', 'Group') + `); + sqlite.executeSync( + 'CREATE TABLE __migration_Chapter AS SELECT * FROM Chapter', + ); + + repairChapterMigrationSnapshot(sqlite); + + expect( + sqlite.executeSync( + 'SELECT id, scanlator, timeSpent FROM __migration_Chapter', + ).rows, + ).toEqual([{ id: 1, scanlator: 'Group', timeSpent: 0 }]); + } finally { + sqlite.close(); + } + }); + it('recovers when Chapter columns exist without migration records', async () => { const sqlite = open({ name: ':memory:' }); (sqlite as any).executeAsync ??= sqlite.execute; diff --git a/src/database/db.ts b/src/database/db.ts index 70a39961..560e4c1a 100644 --- a/src/database/db.ts +++ b/src/database/db.ts @@ -36,11 +36,13 @@ 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, }, @@ -108,6 +110,32 @@ export const repairInterruptedNovelMigration = ( 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};`, + ); + } +}; + /** * Repairs migration metadata created by older Drizzle versions and interrupted * migrations. SQLite cannot add a column conditionally, so an existing column @@ -232,6 +260,7 @@ export const initializeDatabase = () => { if (!initialization) { setPragmas(_db); repairInterruptedNovelMigration(_db); + repairChapterMigrationSnapshot(_db); repairMigrationHistory(_db); initialization = migrate(drizzleDb, getPendingMigrations(_db)).then(() => { runDatabaseBootstrap(_db);