diff --git a/src/main/db.zig b/src/main/db.zig index 589511e..87668f8 100644 --- a/src/main/db.zig +++ b/src/main/db.zig @@ -1,6 +1,7 @@ const std = @import("std"); const sql = @import("sql"); const models = @import("./db/models.zig"); +const migrations = @import("./db/migrations.zig"); const util = @import("util"); const Uuid = util.Uuid; @@ -151,88 +152,11 @@ fn getAlloc(row: sql.Row, comptime T: type, idx: u15, alloc: std.mem.Allocator) pub const Database = struct { db: sql.Sqlite, - const init_sql_stmts = [_][]const u8{ - \\CREATE TABLE IF NOT EXISTS - \\user( - \\ id TEXT NOT NULL PRIMARY KEY, - \\ username TEXT NOT NULL, - \\ - \\ created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP - \\) STRICT; - , - \\CREATE TABLE IF NOT EXISTS - \\actor( - \\ user_id TEXT NOT NULL PRIMARY KEY REFERENCES user(id), - \\ public_id TEXT NOT NULL - \\) STRICT; - , - \\CREATE TABLE IF NOT EXISTS - \\local_user( - \\ user_id TEXT NOT NULL PRIMARY KEY REFERENCES user(id), - \\ - \\ email TEXT, - \\ - \\ hashed_password TEXT NOT NULL, - \\ password_changed_at INTEGER NOT NULL - \\) STRICT; - , - \\CREATE TABLE IF NOT EXISTS - \\note( - \\ id TEXT NOT NULL, - \\ - \\ content TEXT NOT NULL, - \\ author_id TEXT NOT NULL REFERENCES actor(id), - \\ - \\ created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP - \\) STRICT; - , - \\CREATE TABLE IF NOT EXISTS - \\reaction( - \\ id TEXT NOT NULL PRIMARY KEY, - \\ - \\ reactor_id TEXT NOT NULL REFERENCES actor(id), - \\ note_id TEXT NOT NULL REFERENCES note(id), - \\ - \\ created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP - \\) STRICT; - , - \\CREATE TABLE IF NOT EXISTS - \\token( - \\ id TEXT NOT NULL PRIMARY KEY, - \\ - \\ hash BLOB UNIQUE NOT NULL, - \\ user_id TEXT NOT NULL REFERENCES local_user(id), - \\ - \\ issued_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP - \\) STRICT; - , - \\CREATE TABLE IF NOT EXISTS - \\invite( - \\ id TEXT NOT NULL PRIMARY KEY, - \\ - \\ name TEXT NOT NULL, - \\ invite_code TEXT NOT NULL UNIQUE, - \\ created_by TEXT NOT NULL REFERENCES local_user(id), - \\ - \\ max_uses INTEGER, - \\ - \\ created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, - \\ expires_at INTEGER - \\) STRICT; - , - \\ALTER TABLE local_user ADD COLUMN invite_id TEXT REFERENCES invite(id); - }; - pub fn init() !Database { var db = try sql.Sqlite.open("./test.db"); errdefer db.close(); - for (init_sql_stmts) |stmt_sql| { - var stmt = try db.prepare(stmt_sql); - defer stmt.finalize(); - - while (try stmt.step()) |_| {} - } + try migrations.up(&db); return Database{ .db = db }; } diff --git a/src/main/db/migrations.zig b/src/main/db/migrations.zig new file mode 100644 index 0000000..022de11 --- /dev/null +++ b/src/main/db/migrations.zig @@ -0,0 +1,175 @@ +const sql = @import("sql"); +const DateTime = @import("util").DateTime; + +pub const Migration = struct { + name: []const u8, + up: []const u8, + down: []const u8, +}; + +fn firstIndexOf(str: []const u8, char: u8) ?usize { + for (str) |ch, i| { + if (ch == char) return i; + } + + return null; +} + +fn execStmt(db: *sql.Sqlite, stmt_sql: []const u8) !void { + const stmt = try db.prepare(stmt_sql); + defer stmt.finalize(); + while (try stmt.step()) |_| {} +} + +fn execScript(db: *sql.Sqlite, script: []const u8) !void { + try execStmt(db, "BEGIN;"); + errdefer { + _ = execStmt(db, "ROLLBACK;") catch unreachable; + } + + var remaining = script; + while (firstIndexOf(remaining, ';')) |last| { + try execStmt(db, remaining[0 .. last + 1]); + + remaining = remaining[last + 1 ..]; + } + + try execStmt(db, "COMMIT;"); +} + +fn wasMigrationRan(db: *sql.Sqlite, name: []const u8) !bool { + const stmt = try db.prepare("SELECT COUNT(*) FROM migration WHERE name = ?;"); + defer stmt.finalize(); + + try stmt.bindText(1, name); + const result = (try stmt.step()).?; + + const count = try result.getI64(0); + return count != 0; +} + +fn markMigrationAsRan(db: *sql.Sqlite, name: []const u8) !void { + const stmt = try db.prepare("INSERT INTO migration(name) VALUES(?);"); + defer stmt.finalize(); + + try stmt.bindText(1, name); + _ = try stmt.step(); +} + +pub fn up(db: *sql.Sqlite) !void { + try execScript(db, create_migration_table); + + for (migrations) |migration| { + if (!try wasMigrationRan(db, migration.name)) { + try execScript(db, migration.up); + try markMigrationAsRan(db, migration.name); + } + } +} + +const create_migration_table = + \\CREATE TABLE IF NOT EXISTS + \\migration( + \\ name TEXT NOT NULL PRIMARY KEY, + \\ applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + \\) STRICT; +; + +// NOTE: Until the first public release, i may collapse multiple +// migrations into a single one. this will require db recreation +const migrations: []const Migration = &.{ + .{ + .name = "users and actors", + .up = + \\CREATE TABLE user( + \\ id TEXT NOT NULL PRIMARY KEY, + \\ username TEXT NOT NULL, + \\ + \\ created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP + \\) STRICT; + \\ + \\CREATE TABLE actor( + \\ user_id TEXT NOT NULL PRIMARY KEY REFERENCES user(id), + \\ public_id TEXT NOT NULL + \\) STRICT; + \\ + \\CREATE TABLE local_user( + \\ user_id TEXT NOT NULL PRIMARY KEY REFERENCES user(id), + \\ + \\ email TEXT, + \\ + \\ hashed_password TEXT NOT NULL, + \\ password_changed_at INTEGER NOT NULL + \\) STRICT; + , + .down = + \\DROP TABLE local_user; + \\DROP TABLE actor; + \\DROP TABLE user; + , + }, + .{ + .name = "notes", + .up = + \\CREATE TABLE note( + \\ id TEXT NOT NULL, + \\ + \\ content TEXT NOT NULL, + \\ author_id TEXT NOT NULL REFERENCES actor(id), + \\ + \\ created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP + \\) STRICT; + , + .down = "DROP TABLE note;", + }, + .{ + .name = "note reactions", + .up = + \\CREATE TABLE reaction( + \\ id TEXT NOT NULL PRIMARY KEY, + \\ + \\ reactor_id TEXT NOT NULL REFERENCES actor(id), + \\ note_id TEXT NOT NULL REFERENCES note(id), + \\ + \\ created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP + \\) STRICT; + , + .down = "DROP TABLE reaction;", + }, + .{ + .name = "user tokens", + .up = + \\CREATE TABLE token( + \\ id TEXT NOT NULL PRIMARY KEY, + \\ + \\ hash BLOB UNIQUE NOT NULL, + \\ user_id TEXT NOT NULL REFERENCES local_user(id), + \\ + \\ issued_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP + \\) STRICT; + , + .down = "DROP TABLE token;", + }, + .{ + .name = "user invites", + .up = + \\CREATE TABLE invite( + \\ id TEXT NOT NULL PRIMARY KEY, + \\ + \\ name TEXT NOT NULL, + \\ invite_code TEXT NOT NULL UNIQUE, + \\ created_by TEXT NOT NULL REFERENCES local_user(id), + \\ + \\ max_uses INTEGER, + \\ + \\ created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, + \\ expires_at INTEGER + \\) STRICT; + \\ALTER TABLE local_user ADD COLUMN invite_id TEXT REFERENCES invite(id); + , + .down = + \\ALTER TABLE local_user DROP COLUMN invite_id; + \\DROP TABLE invite; + , + }, +};