From 25642fbe98980280b8d908a41efdcd72d6972967 Mon Sep 17 00:00:00 2001 From: Reto Brunner Date: Wed, 28 Dec 2022 13:43:03 +0100 Subject: [PATCH 1/6] sqlite: delete table creation test This just repeats the hard coded values from the code, which is not helping. We need to touch that test whenever we modify the sql which is undesired and it doesn't test any useful functionality. Any error that may ensue would hopefully be tracked by the other test. --- test/plugins/sqlite.ts | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/test/plugins/sqlite.ts b/test/plugins/sqlite.ts index 16c832f1..a3c939e7 100644 --- a/test/plugins/sqlite.ts +++ b/test/plugins/sqlite.ts @@ -48,29 +48,6 @@ describe("SQLite Message Storage", function () { store.isEnabled = true; }); - it("should create tables", function (done) { - store.database.all( - "SELECT name, tbl_name, sql FROM sqlite_master WHERE type = 'table'", - (err, row) => { - expect(err).to.be.null; - expect(row).to.deep.equal([ - { - name: "options", - tbl_name: "options", - sql: "CREATE TABLE options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))", - }, - { - name: "messages", - tbl_name: "messages", - sql: "CREATE TABLE messages (network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)", - }, - ]); - - done(); - } - ); - }); - it("should insert schema version to options table", function (done) { store.database.get( "SELECT value FROM options WHERE name = 'schema_version'", From 063aca948ccd6aa99163ae4dbf922d8b069eaf60 Mon Sep 17 00:00:00 2001 From: Reto Brunner Date: Wed, 28 Dec 2022 13:52:24 +0100 Subject: [PATCH 2/6] sqlite: don't hardcode version test --- server/plugins/messageStorage/sqlite.ts | 2 +- test/plugins/sqlite.ts | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/server/plugins/messageStorage/sqlite.ts b/server/plugins/messageStorage/sqlite.ts index 7441687d..9f3766fd 100644 --- a/server/plugins/messageStorage/sqlite.ts +++ b/server/plugins/messageStorage/sqlite.ts @@ -23,7 +23,7 @@ try { ); } -const currentSchemaVersion = 1520239200; +export const currentSchemaVersion = 1520239200; const schema = [ // Schema version #1 diff --git a/test/plugins/sqlite.ts b/test/plugins/sqlite.ts index a3c939e7..5f0d8b0a 100644 --- a/test/plugins/sqlite.ts +++ b/test/plugins/sqlite.ts @@ -5,7 +5,7 @@ import {expect} from "chai"; import util from "../util"; import Msg, {MessageType} from "../../server/models/msg"; import Config from "../../server/config"; -import MessageStorage from "../../server/plugins/messageStorage/sqlite"; +import MessageStorage, {currentSchemaVersion} from "../../server/plugins/messageStorage/sqlite"; describe("SQLite Message Storage", function () { // Increase timeout due to unpredictable I/O on CI services @@ -53,11 +53,8 @@ describe("SQLite Message Storage", function () { "SELECT value FROM options WHERE name = 'schema_version'", (err, row) => { expect(err).to.be.null; - - // Should be sqlite.currentSchemaVersion, // compared as string because it's returned as such from the database - expect(row.value).to.equal("1520239200"); - + expect(row.value).to.equal(currentSchemaVersion.toString()); done(); } ); From 899762cddd7928f808ec97bee15e89094dab436d Mon Sep 17 00:00:00 2001 From: Reto Brunner Date: Wed, 28 Dec 2022 14:57:36 +0100 Subject: [PATCH 3/6] sqlite: Add infrastructure for migration tests This sets up the testing infrastructure to test migrations we are doing. It's done on a in memory database directly, we are only interested in the statements themselves and it's easier than to try and inject a prepared db into the store. We do add some dummy data though to make sure we actually execute the things as we expect. --- server/plugins/messageStorage/sqlite.ts | 13 +++- test/plugins/sqlite.ts | 97 ++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/server/plugins/messageStorage/sqlite.ts b/server/plugins/messageStorage/sqlite.ts index 9f3766fd..3bd67baa 100644 --- a/server/plugins/messageStorage/sqlite.ts +++ b/server/plugins/messageStorage/sqlite.ts @@ -23,16 +23,21 @@ try { ); } -export const currentSchemaVersion = 1520239200; +type Migration = {version: number; stmts: string[]}; +export const currentSchemaVersion = 1520239200; // use `new Date().getTime()` + +// Desired schema, adapt to the newest version and add migrations to the array below const schema = [ - // Schema version #1 "CREATE TABLE IF NOT EXISTS options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))", "CREATE TABLE IF NOT EXISTS messages (network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)", "CREATE INDEX IF NOT EXISTS network_channel ON messages (network, channel)", "CREATE INDEX IF NOT EXISTS time ON messages (time)", ]; +// the migrations will be executed in an exclusive transaction as a whole +export const migrations = []; + class Deferred { resolve!: () => void; promise: Promise; @@ -326,4 +331,8 @@ function parseSearchRowsToMessages(id: number, rows: any[]) { return messages; } +export function necessaryMigrations(since: number): Migration[] { + return migrations.filter((m) => m.version > since); +} + export default SqliteMessageStorage; diff --git a/test/plugins/sqlite.ts b/test/plugins/sqlite.ts index 5f0d8b0a..e2723872 100644 --- a/test/plugins/sqlite.ts +++ b/test/plugins/sqlite.ts @@ -5,7 +5,102 @@ import {expect} from "chai"; import util from "../util"; import Msg, {MessageType} from "../../server/models/msg"; import Config from "../../server/config"; -import MessageStorage, {currentSchemaVersion} from "../../server/plugins/messageStorage/sqlite"; +import MessageStorage, { + currentSchemaVersion, + migrations, + necessaryMigrations, +} from "../../server/plugins/messageStorage/sqlite"; +import Client from "../../server/client"; +import sqlite3 from "sqlite3"; + +const orig_schema = [ + // Schema version #1 + // DO NOT CHANGE THIS IN ANY WAY, it's needed to properly test migrations + "CREATE TABLE IF NOT EXISTS options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))", + "CREATE TABLE IF NOT EXISTS messages (network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)", + "CREATE INDEX IF NOT EXISTS network_channel ON messages (network, channel)", + "CREATE INDEX IF NOT EXISTS time ON messages (time)", +]; + +const v1_schema_version = 1520239200; + +const v1_dummy_messages = [ + { + network: "8f650427-79a2-4950-b8af-94088b61b37c", + channel: "##linux", + time: 1594845354280, + type: "message", + msg: '{"from":{"mode":"","nick":"rascul"},"text":"db on a flash drive doesn\'t sound very nice though","self":false,"highlight":false,"users":[]}', + }, + { + network: "8f650427-79a2-4950-b8af-94088b61b37c", + channel: "##linux", + time: 1594845357234, + type: "message", + msg: '{"from":{"mode":"","nick":"GrandPa-G"},"text":"that\'s the point of changing to make sure.","self":false,"highlight":false,"users":[]}', + }, + { + network: "8f650427-79a2-4950-b8af-94088b61b37c", + channel: "#pleroma-dev", + time: 1594845358464, + type: "message", + msg: '{"from":{"mode":"@","nick":"rinpatch"},"text":"it\'s complicated","self":false,"highlight":false,"users":[]}', + }, +]; + +describe("SQLite migrations", function () { + let db: sqlite3.Database; + + function serialize_run(stmt: string, ...params: any[]): Promise { + return new Promise((resolve, reject) => { + db.serialize(() => { + db.run(stmt, params, (err) => { + if (err) { + reject(err); + return; + } + + resolve(); + }); + }); + }); + } + + before(async function () { + db = new sqlite3.Database(":memory:"); + + for (const stmt of orig_schema) { + await serialize_run(stmt); + } + + for (const msg of v1_dummy_messages) { + await serialize_run( + "INSERT INTO messages(network, channel, time, type, msg) VALUES(?, ?, ?, ?, ?)", + msg.network, + msg.channel, + msg.time, + msg.type, + msg.msg + ); + } + }); + + after(function (done) { + db.close(done); + }); + + it("has working migrations", async function () { + const to_execute = necessaryMigrations(v1_schema_version); + expect(to_execute.length).to.eq(migrations.length); + await serialize_run("BEGIN EXCLUSIVE TRANSACTION"); + + for (const stmt of to_execute.map((m) => m.stmts).flat()) { + await serialize_run(stmt); + } + + await serialize_run("COMMIT TRANSACTION"); + }); +}); describe("SQLite Message Storage", function () { // Increase timeout due to unpredictable I/O on CI services From 86e376fc037eb0757a0695f764215af5a64dc4e6 Mon Sep 17 00:00:00 2001 From: Reto Brunner Date: Thu, 29 Dec 2022 14:35:27 +0100 Subject: [PATCH 4/6] sqlite: run migrations on startup --- server/plugins/messageStorage/sqlite.ts | 87 ++++++++++++++++++++----- 1 file changed, 69 insertions(+), 18 deletions(-) diff --git a/server/plugins/messageStorage/sqlite.ts b/server/plugins/messageStorage/sqlite.ts index 3bd67baa..6cee2a8b 100644 --- a/server/plugins/messageStorage/sqlite.ts +++ b/server/plugins/messageStorage/sqlite.ts @@ -36,7 +36,20 @@ const schema = [ ]; // the migrations will be executed in an exclusive transaction as a whole -export const migrations = []; +// add new migrations to the end, with the version being the new 'currentSchemaVersion' +export const migrations: Migration[] = [ + { + version: 1672236339873, + stmts: [ + "CREATE TABLE messages_new (id INTEGER PRIMARY KEY AUTOINCREMENT, network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT);", + "INSERT INTO messages_new(network, channel, time, type, msg) select network, channel, time, type, msg from messages order by time asc;", + "DROP TABLE messages;", + "ALTER TABLE messages_new RENAME TO messages;", + "CREATE INDEX network_channel ON messages (network, channel);", + "CREATE INDEX time ON messages (time);", + ], + }, +]; class Deferred { resolve!: () => void; @@ -91,43 +104,81 @@ class SqliteMessageStorage implements SearchableMessageStorage { } } - async run_migrations() { + async setup_new_db() { for (const stmt of schema) { await this.serialize_run(stmt, []); } + await this.serialize_run("INSERT INTO options (name, value) VALUES ('schema_version', ?)", [ + currentSchemaVersion.toString(), + ]); + } + + async current_version(): Promise { + const have_options = await this.serialize_get( + "select 1 from sqlite_master where type = 'table' and name = 'options'" + ); + + if (!have_options) { + return 0; + } + const version = await this.serialize_get( "SELECT value FROM options WHERE name = 'schema_version'" ); if (version === undefined) { - // new table - await this.serialize_run( - "INSERT INTO options (name, value) VALUES ('schema_version', ?)", - [currentSchemaVersion] - ); - return; + // technically shouldn't happen, means something created a schema but didn't populate it + // we'll try our best to recover + return 0; } const storedSchemaVersion = parseInt(version.value, 10); + return storedSchemaVersion; + } - if (storedSchemaVersion === currentSchemaVersion) { - return; - } - - if (storedSchemaVersion > currentSchemaVersion) { - throw `sqlite messages schema version is higher than expected (${storedSchemaVersion} > ${currentSchemaVersion}). Is The Lounge out of date?`; - } - + async _run_migrations(dbVersion: number) { log.info( - `sqlite messages schema version is out of date (${storedSchemaVersion} < ${currentSchemaVersion}). Running migrations if any.` + `sqlite messages schema version is out of date (${dbVersion} < ${currentSchemaVersion}). Running migrations.` ); + const to_execute = necessaryMigrations(dbVersion); + + for (const stmt of to_execute.map((m) => m.stmts).flat()) { + await this.serialize_run(stmt, []); + } + await this.serialize_run("UPDATE options SET value = ? WHERE name = 'schema_version'", [ - currentSchemaVersion, + currentSchemaVersion.toString(), ]); } + async run_migrations() { + const version = await this.current_version(); + + if (version > currentSchemaVersion) { + throw `sqlite messages schema version is higher than expected (${version} > ${currentSchemaVersion}). Is The Lounge out of date?`; + } else if (version === currentSchemaVersion) { + return; // nothing to do + } + + await this.serialize_run("BEGIN EXCLUSIVE TRANSACTION", []); + + try { + if (version === 0) { + await this.setup_new_db(); + } else { + await this._run_migrations(version); + } + } catch (err) { + await this.serialize_run("ROLLBACK", []); + throw err; + } + + await this.serialize_run("COMMIT", []); + await this.serialize_run("VACUUM", []); + } + async close() { if (!this.isEnabled) { return; From 3e7255ff20926abeb523691ccebad6371404febb Mon Sep 17 00:00:00 2001 From: Reto Brunner Date: Thu, 29 Dec 2022 14:35:57 +0100 Subject: [PATCH 5/6] sqlite: Add primary keys to the messages table We want primary keys to never get re-used to so that we can implement jump to messages / context fetching etc in the future. This isn't hooked up yet at all to the rest of the code, only the schema is changed --- server/plugins/messageStorage/sqlite.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/plugins/messageStorage/sqlite.ts b/server/plugins/messageStorage/sqlite.ts index 6cee2a8b..349d2ec2 100644 --- a/server/plugins/messageStorage/sqlite.ts +++ b/server/plugins/messageStorage/sqlite.ts @@ -25,12 +25,12 @@ try { type Migration = {version: number; stmts: string[]}; -export const currentSchemaVersion = 1520239200; // use `new Date().getTime()` +export const currentSchemaVersion = 1672236339873; // use `new Date().getTime()` // Desired schema, adapt to the newest version and add migrations to the array below const schema = [ "CREATE TABLE IF NOT EXISTS options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))", - "CREATE TABLE IF NOT EXISTS messages (network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)", + "CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)", "CREATE INDEX IF NOT EXISTS network_channel ON messages (network, channel)", "CREATE INDEX IF NOT EXISTS time ON messages (time)", ]; From 3be805bd38018f4fa83778f00063b58746bfb175 Mon Sep 17 00:00:00 2001 From: Reto Brunner Date: Sat, 25 Mar 2023 18:27:50 +0100 Subject: [PATCH 6/6] sqlite: Add rollback support This enables db migrations to be undone, or "down migrated". The down migration shouldn't be done automatically as it could lead to severe data loss if that were done. Hence, we still hard fail if we encounter a version lower than what we have in the DB. A CLI will be added in a later commit that allows users to explicitly do that. --- server/plugins/messageStorage/sqlite.ts | 195 ++++++++++++++++++++++-- test/plugins/sqlite.ts | 91 +++++++++-- 2 files changed, 261 insertions(+), 25 deletions(-) diff --git a/server/plugins/messageStorage/sqlite.ts b/server/plugins/messageStorage/sqlite.ts index 349d2ec2..e0a32859 100644 --- a/server/plugins/messageStorage/sqlite.ts +++ b/server/plugins/messageStorage/sqlite.ts @@ -24,31 +24,73 @@ try { } type Migration = {version: number; stmts: string[]}; +type Rollback = {version: number; rollback_forbidden?: boolean; stmts: string[]}; -export const currentSchemaVersion = 1672236339873; // use `new Date().getTime()` +export const currentSchemaVersion = 1679743888000; // use `new Date().getTime()` // Desired schema, adapt to the newest version and add migrations to the array below const schema = [ - "CREATE TABLE IF NOT EXISTS options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))", - "CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)", - "CREATE INDEX IF NOT EXISTS network_channel ON messages (network, channel)", - "CREATE INDEX IF NOT EXISTS time ON messages (time)", + "CREATE TABLE options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))", + "CREATE TABLE messages (id INTEGER PRIMARY KEY AUTOINCREMENT, network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)", + `CREATE TABLE migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version INTEGER NOT NULL UNIQUE, + rollback_forbidden INTEGER DEFAULT 0 NOT NULL + )`, + `CREATE TABLE rollback_steps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + migration_id INTEGER NOT NULL REFERENCES migrations ON DELETE CASCADE, + step INTEGER NOT NULL, + statement TEXT NOT NULL + )`, + "CREATE INDEX network_channel ON messages (network, channel)", + "CREATE INDEX time ON messages (time)", ]; // the migrations will be executed in an exclusive transaction as a whole // add new migrations to the end, with the version being the new 'currentSchemaVersion' +// write a corresponding down migration into rollbacks export const migrations: Migration[] = [ { version: 1672236339873, stmts: [ - "CREATE TABLE messages_new (id INTEGER PRIMARY KEY AUTOINCREMENT, network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT);", - "INSERT INTO messages_new(network, channel, time, type, msg) select network, channel, time, type, msg from messages order by time asc;", - "DROP TABLE messages;", - "ALTER TABLE messages_new RENAME TO messages;", - "CREATE INDEX network_channel ON messages (network, channel);", - "CREATE INDEX time ON messages (time);", + "CREATE TABLE messages_new (id INTEGER PRIMARY KEY AUTOINCREMENT, network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)", + "INSERT INTO messages_new(network, channel, time, type, msg) select network, channel, time, type, msg from messages order by time asc", + "DROP TABLE messages", + "ALTER TABLE messages_new RENAME TO messages", + "CREATE INDEX network_channel ON messages (network, channel)", + "CREATE INDEX time ON messages (time)", ], }, + { + version: 1679743888000, + stmts: [ + `CREATE TABLE IF NOT EXISTS migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + version INTEGER NOT NULL UNIQUE, + rollback_forbidden INTEGER DEFAULT 0 NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS rollback_steps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + migration_id INTEGER NOT NULL REFERENCES migrations ON DELETE CASCADE, + step INTEGER NOT NULL, + statement TEXT NOT NULL + )`, + ], + }, +]; + +// down migrations need to restore the state of the prior version. +// rollback can be disallowed by adding rollback_forbidden: true to it +export const rollbacks: Rollback[] = [ + { + version: 1672236339873, + stmts: [], // changes aren't visible, left empty on purpose + }, + { + version: 1679743888000, + stmts: [], // here we can't drop the tables, as we use them in the code, so just leave those in + }, ]; class Deferred { @@ -89,6 +131,7 @@ class SqliteMessageStorage implements SearchableMessageStorage { this.database = new sqlite3.Database(sqlitePath); try { + await this.run_pragmas(); // must be done outside of a transaction await this.run_migrations(); } catch (e) { this.isEnabled = false; @@ -137,6 +180,12 @@ class SqliteMessageStorage implements SearchableMessageStorage { return storedSchemaVersion; } + async update_version_in_db() { + return this.serialize_run("UPDATE options SET value = ? WHERE name = 'schema_version'", [ + currentSchemaVersion.toString(), + ]); + } + async _run_migrations(dbVersion: number) { log.info( `sqlite messages schema version is out of date (${dbVersion} < ${currentSchemaVersion}). Running migrations.` @@ -148,9 +197,11 @@ class SqliteMessageStorage implements SearchableMessageStorage { await this.serialize_run(stmt, []); } - await this.serialize_run("UPDATE options SET value = ? WHERE name = 'schema_version'", [ - currentSchemaVersion.toString(), - ]); + await this.update_version_in_db(); + } + + async run_pragmas() { + await this.serialize_run("PRAGMA foreign_keys = ON;", []); } async run_migrations() { @@ -170,6 +221,8 @@ class SqliteMessageStorage implements SearchableMessageStorage { } else { await this._run_migrations(version); } + + await this.insert_rollback_since(version); } catch (err) { await this.serialize_run("ROLLBACK", []); throw err; @@ -198,6 +251,116 @@ class SqliteMessageStorage implements SearchableMessageStorage { }); } + async fetch_rollbacks(since_version: number) { + const res = await this.serialize_fetchall( + `select version, rollback_forbidden, statement + from rollback_steps + join migrations on migrations.id=rollback_steps.migration_id + where version > ? + order by version desc, step asc`, + since_version + ); + const result: Rollback[] = []; + + // convert to Rollback[] + // requires ordering in the sql statement + for (const raw of res) { + const last = result.at(-1); + + if (!last || raw.version !== last.version) { + result.push({ + version: raw.version, + rollback_forbidden: Boolean(raw.rollback_forbidden), + stmts: [raw.statement], + }); + } else { + last.stmts.push(raw.statment); + } + } + + return result; + } + + async delete_migrations_older_than(version: number) { + return this.serialize_run("delete from migrations where migrations.version > ?", [version]); + } + + async _downgrade_to(version: number) { + const _rollbacks = await this.fetch_rollbacks(version); + + if (_rollbacks.length === 0) { + return version; + } + + const forbidden = _rollbacks.find((item) => item.rollback_forbidden); + + if (forbidden) { + throw Error(`can't downgrade past ${forbidden.version}`); + } + + for (const rollback of _rollbacks) { + for (const stmt of rollback.stmts) { + await this.serialize_run(stmt, []); + } + } + + await this.delete_migrations_older_than(version); + await this.update_version_in_db(); + + return _rollbacks.at(-1)!.version; // assert valid due to length guard above + } + + async downgrade_to(version: number) { + if (version <= 0) { + throw Error(`${version} is not a valid version to downgrade to`); + } + + await this.serialize_run("BEGIN EXCLUSIVE TRANSACTION", []); + + let new_version: number; + + try { + new_version = await this._downgrade_to(version); + } catch (err) { + await this.serialize_run("ROLLBACK", []); + throw err; + } + + await this.serialize_run("COMMIT", []); + return new_version; + } + + async downgrade() { + const res = await this.downgrade_to(currentSchemaVersion); + return res; + } + + async insert_rollback_since(version: number) { + const missing = newRollbacks(version); + + for (const rollback of missing) { + const migration = await this.serialize_get( + `insert into migrations + (version, rollback_forbidden) + values (?, ?) + returning id`, + rollback.version, + rollback.rollback_forbidden || 0 + ); + + for (const stmt of rollback.stmts) { + let step = 0; + await this.serialize_run( + `insert into rollback_steps + (migration_id, step, statement) + values (?, ?, ?)`, + [migration.id, step, stmt] + ); + step++; + } + } + } + async index(network: Network, channel: Chan, msg: Msg) { await this.initDone.promise; @@ -386,4 +549,8 @@ export function necessaryMigrations(since: number): Migration[] { return migrations.filter((m) => m.version > since); } +export function newRollbacks(since: number): Rollback[] { + return rollbacks.filter((r) => r.version > since); +} + export default SqliteMessageStorage; diff --git a/test/plugins/sqlite.ts b/test/plugins/sqlite.ts index e2723872..1ce745cd 100644 --- a/test/plugins/sqlite.ts +++ b/test/plugins/sqlite.ts @@ -9,8 +9,8 @@ import MessageStorage, { currentSchemaVersion, migrations, necessaryMigrations, + rollbacks, } from "../../server/plugins/messageStorage/sqlite"; -import Client from "../../server/client"; import sqlite3 from "sqlite3"; const orig_schema = [ @@ -89,7 +89,14 @@ describe("SQLite migrations", function () { db.close(done); }); - it("has working migrations", async function () { + it("has a down migration for every migration", function () { + expect(migrations.length).to.eq(rollbacks.length); + expect(migrations.map((m) => m.version)).to.have.ordered.members( + rollbacks.map((r) => r.version) + ); + }); + + it("has working up-migrations", async function () { const to_execute = necessaryMigrations(v1_schema_version); expect(to_execute.length).to.eq(migrations.length); await serialize_run("BEGIN EXCLUSIVE TRANSACTION"); @@ -100,6 +107,24 @@ describe("SQLite migrations", function () { await serialize_run("COMMIT TRANSACTION"); }); + + it("has working down-migrations", async function () { + await serialize_run("BEGIN EXCLUSIVE TRANSACTION"); + + for (const rollback of rollbacks.reverse()) { + if (rollback.rollback_forbidden) { + throw Error( + "Try to write a down migration, if you really can't, flip this to a break" + ); + } + + for (const stmt of rollback.stmts) { + await serialize_run(stmt); + } + } + + await serialize_run("COMMIT TRANSACTION"); + }); }); describe("SQLite Message Storage", function () { @@ -110,6 +135,36 @@ describe("SQLite Message Storage", function () { const expectedPath = path.join(Config.getHomePath(), "logs", "testUser.sqlite3"); let store: MessageStorage; + function db_get_one(stmt: string, ...params: any[]): Promise { + return new Promise((resolve, reject) => { + store.database.serialize(() => { + store.database.get(stmt, params, (err, row) => { + if (err) { + reject(err); + return; + } + + resolve(row); + }); + }); + }); + } + + function db_get_mult(stmt: string, ...params: any[]): Promise { + return new Promise((resolve, reject) => { + store.database.serialize(() => { + store.database.all(stmt, params, (err, rows) => { + if (err) { + reject(err); + return; + } + + resolve(rows); + }); + }); + }); + } + before(function (done) { store = new MessageStorage("testUser"); @@ -143,16 +198,17 @@ describe("SQLite Message Storage", function () { store.isEnabled = true; }); - it("should insert schema version to options table", function (done) { - store.database.get( - "SELECT value FROM options WHERE name = 'schema_version'", - (err, row) => { - expect(err).to.be.null; - // compared as string because it's returned as such from the database - expect(row.value).to.equal(currentSchemaVersion.toString()); - done(); - } + it("should insert schema version to options table", async function () { + const row = await db_get_one("SELECT value FROM options WHERE name = 'schema_version'"); + expect(row.value).to.equal(currentSchemaVersion.toString()); + }); + + it("should insert migrations", async function () { + const row = await db_get_one( + "SELECT id, version FROM migrations WHERE version = ?", + currentSchemaVersion ); + expect(row).to.not.be.undefined; }); it("should store a message", async function () { @@ -299,6 +355,19 @@ describe("SQLite Message Storage", function () { } }); + it("should be able to downgrade", async function () { + for (const rollback of rollbacks.reverse()) { + if (rollback.rollback_forbidden) { + throw Error( + "Try to write a down migration, if you really can't, flip this to a break" + ); + } + + const new_version = await store.downgrade_to(rollback.version); + expect(new_version).to.equal(rollback.version); + } + }); + it("should close database", async function () { await store.close(); expect(fs.existsSync(expectedPath)).to.be.true;