sql: Add "maintenance transaction" system

SQLite's ALTER TABLE functionality leaves a lot to be desired, so
in practice the way you alter SQLite schema is to create a new
table, migrate the data into it, then drop the old table. However,
this is potentially complicated by the presence of foreign key
constraints.

In order to resolve the foreign key constraint issue we add a
`maintenance()` method to the `DataSource` abstraction layer. This
operates similarly to the existing `transaction()` method, but the
SQLite implementation of this method defers enforcement of foreign
keys until the transaction is about to commit.
This commit is contained in:
Tau 2020-09-26 23:58:41 -04:00 committed by da5669c09fdb0a288ba01e259a609d7779ac7fc9
parent 6090a60467
commit eb276128c2
4 changed files with 88 additions and 1 deletions

View File

@ -106,7 +106,7 @@ async function migratedb(
export default async function checkdb(db: DataSource): Promise<void> {
let schemaver: number | undefined;
await db.transaction(async txn => {
await db.maintenance(async txn => {
const stmt = sql.select("schemaver").from("meta");
try {

View File

@ -45,6 +45,16 @@ export interface DataSource {
*/
transaction<T>(callback: (txn: Transaction) => Promise<T>): Promise<T>;
/**
* Execute an async callback in the context of a "maintenance" transaction.
*
* This method should be used when altering the data store's schema. On
* SQLite it will temporarily disable foreign key constraints (and then
* re-enable and validate them immediately prior to committing the
* transaction) so that tables may be dropped and re-created.
*/
maintenance<T>(callback: (txn: Transaction) => Promise<T>): Promise<T>;
/**
* Perform housekeeping operations on the database's on-disk data structures.
*

33
src/sql/sqlite.test.ts Normal file
View File

@ -0,0 +1,33 @@
import { Transaction } from "./api";
import { openSqlite } from "./sqlite";
async function violateFk(txn: Transaction) {
await txn.raw("create table foo (id number primary key)");
await txn.raw("create table bar (foo_id integer references foo(id))");
await txn.raw("insert into bar (foo_id) values (1)");
}
test("FK enforcement", async () => {
const db = openSqlite(":memory:");
const promise = db.transaction(violateFk);
expect(promise).rejects.toMatchObject({
message: "FOREIGN KEY constraint failed",
});
});
test("FK violation detection", async () => {
const db = openSqlite(":memory:");
const promise = db.maintenance(violateFk);
expect(promise).rejects.toMatchObject({
fkViolations: [
{
fkid: BigInt(0),
parent: "foo",
rowid: BigInt(1),
table: "bar",
},
],
});
});

View File

@ -1,10 +1,13 @@
import Database, { ResultRow } from "@decafcode/sqlite";
import { randomBytes } from "crypto";
import logger from "debug";
import * as sql from "sql-bricks-postgres";
import { DataSource, Row, Transaction } from "./api";
import { Id } from "../model";
const debug = logger("app:sqlite");
// bless me father for i have sinned
const fuFixup = new RegExp(" FOR UPDATE$");
@ -53,6 +56,12 @@ function _postprocess(obj: ResultRow): Row {
return result;
}
export class MaintenanceError extends Error {
constructor(msg: string, public readonly fkViolations: any[]) {
super(msg);
}
}
class SqliteTransaction implements Transaction {
constructor(private readonly _db: Database) {}
@ -141,6 +150,41 @@ class SqliteDataSource implements DataSource {
}
}
async maintenance<T>(
callback: (txn: Transaction) => Promise<T>
): Promise<T> {
const db = new Database(this._path);
try {
db.exec("pragma foreign_keys=off");
db.exec("begin");
const txn = new SqliteTransaction(db);
const result = await callback(txn);
const fkViolations = db.prepare("pragma foreign_key_check").all();
if (fkViolations.length > 0) {
debug("Foreign key violations: %O", fkViolations);
throw new MaintenanceError(
"Maintenance failed, db unchanged. Consult 'app:sqlite' debug log.",
fkViolations
);
}
db.exec("commit");
return result;
} catch (e) {
db.exec("rollback");
throw e;
} finally {
db.close();
}
}
vacuum(): Promise<void> {
const db = new Database(this._path);