fediglam/src/main/migrations.zig

348 lines
10 KiB
Zig

const std = @import("std");
const sql = @import("sql");
const util = @import("util");
const DateTime = util.DateTime;
pub const Migration = struct {
name: [:0]const u8,
up: []const u8,
down: []const u8,
};
fn execStmt(tx: anytype, stmt: []const u8, alloc: std.mem.Allocator) !void {
const stmt_null = try std.cstr.addNullByte(alloc, stmt);
defer alloc.free(stmt_null);
try tx.exec(stmt_null, {}, null);
}
fn execScript(db: anytype, script: []const u8, alloc: std.mem.Allocator) !void {
const tx = try db.beginOrSavepoint();
errdefer tx.rollback();
var iter = std.mem.split(u8, script, ";");
while (iter.next()) |stmt| {
if (stmt.len == 0) continue;
try execStmt(tx, stmt, alloc);
}
try tx.commitOrRelease();
}
fn wasMigrationRan(db: anytype, name: []const u8, alloc: std.mem.Allocator) !bool {
return if (db.queryRow(
std.meta.Tuple(&.{i32}),
"SELECT COUNT(*) FROM migration WHERE name = $1 LIMIT 1",
.{name},
alloc,
)) |row| row[0] != 0 else |err| switch (err) {
error.NoRows => false,
else => error.DatabaseFailure,
};
}
pub fn up(db: anytype) !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
std.log.info("Running migrations...", .{});
try execScript(db, create_migration_table, gpa.allocator());
for (migrations) |migration| {
const tx = try db.begin();
errdefer tx.rollback();
const was_ran = try wasMigrationRan(tx, migration.name, gpa.allocator());
if (!was_ran) {
std.log.info("Running migration {s}", .{migration.name});
try execScript(tx, migration.up, gpa.allocator());
try tx.insert("migration", .{
.name = @as([]const u8, migration.name),
.applied_at = DateTime.now(),
}, gpa.allocator());
}
try tx.commit();
}
}
const create_migration_table =
\\CREATE TABLE IF NOT EXISTS
\\migration(
\\ name TEXT NOT NULL PRIMARY KEY,
\\ applied_at TIMESTAMPTZ NOT NULL
\\);
;
// 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 = "accounts and actors",
.up =
\\CREATE TABLE actor(
\\ id UUID NOT NULL PRIMARY KEY,
\\ username TEXT NOT NULL,
\\
\\ created_at TIMESTAMPTZ NOT NULL
\\);
\\
\\CREATE TABLE account(
\\ id UUID NOT NULL PRIMARY KEY REFERENCES actor(id),
\\
\\ kind TEXT NOT NULL CHECK (kind IN ('admin', 'user')),
\\ email TEXT
\\);
\\
\\CREATE TABLE password(
\\ account_id UUID NOT NULL PRIMARY KEY REFERENCES account(id),
\\
\\ hash BLOB NOT NULL,
\\ changed_at TIMESTAMPTZ NOT NULL
\\);
,
.down =
\\DROP TABLE password;
\\DROP TABLE account;
\\DROP TABLE actor;
,
},
.{
.name = "notes",
.up =
\\CREATE TABLE note(
\\ id UUID NOT NULL,
\\
\\ content TEXT NOT NULL,
\\ author_id UUID NOT NULL REFERENCES actor(id),
\\
\\ created_at TIMESTAMPTZ NOT NULL
\\);
,
.down = "DROP TABLE note;",
},
.{
.name = "note reactions",
.up =
\\CREATE TABLE reaction(
\\ id UUID NOT NULL PRIMARY KEY,
\\
\\ author_id UUID NOT NULL REFERENCES actor(id),
\\ note_id UUID NOT NULL REFERENCES note(id),
\\
\\ created_at TIMESTAMPTZ NOT NULL
\\);
,
.down = "DROP TABLE reaction;",
},
.{
.name = "account tokens",
.up =
\\CREATE TABLE token(
\\ hash TEXT NOT NULL PRIMARY KEY,
\\ account_id UUID NOT NULL REFERENCES account(id),
\\
\\ issued_at TIMESTAMPTZ NOT NULL
\\);
,
.down = "DROP TABLE token;",
},
.{
.name = "account invites",
.up =
\\CREATE TABLE invite(
\\ id UUID NOT NULL PRIMARY KEY,
\\
\\ name TEXT NOT NULL,
\\ code TEXT NOT NULL UNIQUE,
\\ created_by UUID NOT NULL REFERENCES account(id),
\\
\\ max_uses INTEGER,
\\
\\ created_at TIMESTAMPTZ NOT NULL,
\\ expires_at TIMESTAMPTZ,
\\
\\ kind TEXT NOT NULL CHECK (kind in ('system_user', 'community_owner', 'user'))
\\);
\\ALTER TABLE account ADD COLUMN invite_id UUID REFERENCES invite(id);
,
.down =
\\ALTER TABLE account DROP COLUMN invite_id;
\\DROP TABLE invite;
,
},
.{
.name = "communities",
.up =
\\CREATE TABLE community(
\\ id UUID NOT NULL PRIMARY KEY,
\\
\\ owner_id UUID REFERENCES account(id),
\\ name TEXT NOT NULL,
\\ host TEXT NOT NULL UNIQUE,
\\ scheme TEXT NOT NULL CHECK (scheme IN ('http', 'https')),
\\ kind TEXT NOT NULL CHECK (kind in ('admin', 'local')),
\\
\\ created_at TIMESTAMPTZ NOT NULL
\\);
\\ALTER TABLE actor ADD COLUMN community_id UUID REFERENCES community(id);
\\ALTER TABLE invite ADD COLUMN community_id UUID REFERENCES community(id);
,
.down =
\\ALTER TABLE invite DROP COLUMN community_id;
\\ALTER TABLE actor DROP COLUMN community_id;
\\DROP TABLE community;
,
},
.{
.name = "follows",
.up =
\\CREATE TABLE follow(
\\ id UUID NOT NULL PRIMARY KEY,
\\
\\ followed_by_id UUID NOT NULL,
\\ followee_id UUID NOT NULL,
\\
\\ created_at TIMESTAMPTZ NOT NULL,
\\
\\ UNIQUE(followed_by_id, followee_id)
\\);
,
.down = "DROP TABLE follow",
},
.{
.name = "files",
.up =
\\CREATE TABLE file_upload(
\\ id UUID NOT NULL PRIMARY KEY,
\\
\\ created_by UUID REFERENCES account(id),
\\ size INTEGER NOT NULL,
\\
\\ filename TEXT NOT NULL,
\\ description TEXT,
\\ content_type TEXT,
\\ sensitive BOOLEAN NOT NULL,
\\
\\ is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
\\
\\ created_at TIMESTAMPTZ NOT NULL,
\\ updated_at TIMESTAMPTZ NOT NULL
\\);
\\
\\CREATE TABLE drive_entry(
\\ id UUID NOT NULL PRIMARY KEY,
\\
\\ account_owner_id UUID REFERENCES account(id),
\\ community_owner_id UUID REFERENCES community(id),
\\
\\ name TEXT,
\\ parent_directory_id UUID REFERENCES drive_entry(id),
\\
\\ file_id UUID REFERENCES file_upload(id),
\\
\\ CHECK(
\\ (account_owner_id IS NULL AND community_owner_id IS NOT NULL)
\\ OR (account_owner_id IS NOT NULL AND community_owner_id IS NULL)
\\ ),
\\ CHECK(
\\ (name IS NULL AND parent_directory_id IS NULL AND file_id IS NULL)
\\ OR (name IS NOT NULL AND parent_directory_id IS NOT NULL)
\\ )
\\);
\\CREATE UNIQUE INDEX drive_entry_uniqueness
\\ON drive_entry(
\\ name,
\\ COALESCE(parent_directory_id, ''),
\\ COALESCE(account_owner_id, community_owner_id)
\\);
,
.down =
\\DROP INDEX drive_entry_uniqueness;
\\DROP TABLE drive_entry;
\\DROP TABLE file_upload;
,
},
.{
.name = "drive_entry_path",
.up =
\\CREATE VIEW drive_entry_path(
\\ id,
\\ path,
\\ account_owner_id,
\\ community_owner_id,
\\ kind
\\) AS WITH RECURSIVE full_path(
\\ id,
\\ path,
\\ account_owner_id,
\\ community_owner_id,
\\ kind
\\) AS (
\\ SELECT
\\ id,
\\ '' AS path,
\\ account_owner_id,
\\ community_owner_id,
\\ 'dir' AS kind
\\ FROM drive_entry
\\ WHERE parent_directory_id IS NULL
\\ UNION ALL
\\ SELECT
\\ base.id,
\\ (dir.path || '/' || base.name) AS path,
\\ base.account_owner_id,
\\ base.community_owner_id,
\\ (CASE WHEN base.file_id IS NULL THEN 'dir' ELSE 'file' END) as kind
\\ FROM drive_entry AS base
\\ JOIN full_path AS dir ON
\\ base.parent_directory_id = dir.id
\\ AND base.account_owner_id IS NOT DISTINCT FROM dir.account_owner_id
\\ AND base.community_owner_id IS NOT DISTINCT FROM dir.community_owner_id
\\)
\\SELECT
\\ id,
\\ (CASE WHEN kind = 'dir' THEN path || '/' ELSE path END) AS path,
\\ account_owner_id,
\\ community_owner_id,
\\ kind
\\FROM full_path;
,
.down =
\\DROP VIEW drive_entry_path;
,
},
.{
.name = "create drive root directories",
.up =
\\INSERT INTO drive_entry(
\\ id,
\\ account_owner_id,
\\ community_owner_id,
\\ parent_directory_id,
\\ name,
\\ file_id
\\) SELECT
\\ id,
\\ id AS account_owner_id,
\\ NULL AS community_owner_id,
\\ NULL AS parent_directory_id,
\\ NULL AS name,
\\ NULL AS file_id
\\FROM account;
\\INSERT INTO drive_entry(
\\ id,
\\ account_owner_id,
\\ community_owner_id,
\\ parent_directory_id,
\\ name,
\\ file_id
\\) SELECT
\\ id,
\\ NULL AS account_owner_id,
\\ id AS community_owner_id,
\\ NULL AS parent_directory_id,
\\ NULL AS name,
\\ NULL AS file_id
\\FROM community;
,
.down = "",
},
};