fix: Recover Interrupted Chapter Migrations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user