fix: Recover Interrupted Chapter Migrations

This commit is contained in:
Rajarshee Chatterjee
2026-07-28 20:36:59 +05:30
parent ca24671a2f
commit 5f45e4c7f2
3 changed files with 186 additions and 1 deletions
@@ -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
+121
View File
@@ -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;
+29
View File
@@ -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);