Files
racket-wiki/private/migrations.rkt
T

300 lines
9.9 KiB
Racket

#lang racket/base
(require db
racket/file
racket/path
racket/string
"attachment-references.rkt"
"config.rkt"
"todo.rkt")
(provide current-schema-version
database-schema-version
migrate-database!)
(define current-schema-version 6)
(define schema-1-statements
(list
#<<SQL
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('reader', 'editor', 'admin')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)
SQL
#<<SQL
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL
)
SQL
"CREATE INDEX IF NOT EXISTS sessions_expires_idx ON sessions(expires_at)"
#<<SQL
CREATE TABLE IF NOT EXISTS pages (
id BIGSERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
markdown TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
current_version BIGINT NOT NULL DEFAULT 1,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
created_by TEXT NOT NULL,
updated_by TEXT NOT NULL,
archived BOOLEAN NOT NULL DEFAULT FALSE,
archived_at BIGINT,
archived_by TEXT,
search_document TSVECTOR NOT NULL
)
SQL
"CREATE INDEX IF NOT EXISTS pages_search_idx ON pages USING GIN(search_document)"
"CREATE INDEX IF NOT EXISTS pages_title_idx ON pages(lower(title))"
#<<SQL
CREATE TABLE IF NOT EXISTS page_versions (
id BIGSERIAL PRIMARY KEY,
page_id BIGINT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
version BIGINT NOT NULL,
title TEXT NOT NULL,
markdown TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
author TEXT NOT NULL,
action TEXT NOT NULL,
summary TEXT NOT NULL,
created_at BIGINT NOT NULL,
UNIQUE(page_id, version)
)
SQL
"CREATE INDEX IF NOT EXISTS page_versions_page_idx ON page_versions(page_id, version DESC)"
#<<SQL
CREATE TABLE IF NOT EXISTS attachments (
id BIGSERIAL PRIMARY KEY,
page_id BIGINT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
stored_name TEXT NOT NULL,
size BIGINT NOT NULL,
uploaded_at BIGINT NOT NULL,
uploaded_by TEXT NOT NULL,
UNIQUE(page_id, stored_name)
)
SQL
))
(define (table-exists? db name)
(if (query-maybe-value db "SELECT to_regclass($1) IS NOT NULL" (string-append "public." name))
#t
#f))
(define (ensure-schema-table! db)
(query-exec db
#<<SQL
CREATE TABLE IF NOT EXISTS wiki_schema (
version INTEGER PRIMARY KEY,
applied_at BIGINT NOT NULL
)
SQL
))
(define (database-schema-version db)
(if (table-exists? db "wiki_schema")
(query-value db "SELECT COALESCE(MAX(version), 0) FROM wiki_schema")
0))
(define (record-schema-version! db version)
(query-exec db
"INSERT INTO wiki_schema(version, applied_at) VALUES ($1, $2) ON CONFLICT (version) DO NOTHING"
version
(current-seconds)))
(define (recognize-schema-1? db)
(and (table-exists? db "users")
(table-exists? db "sessions")
(table-exists? db "pages")
(table-exists? db "page_versions")
(table-exists? db "attachments")))
(define (install-schema-1! db)
(for ((statement (in-list schema-1-statements)))
(query-exec db statement))
(ensure-schema-table! db)
(record-schema-version! db 1))
(define (recognize-or-install-schema-1! db)
(cond
((table-exists? db "wiki_schema")
(void))
((recognize-schema-1? db)
(ensure-schema-table! db)
(record-schema-version! db 1))
(else
(install-schema-1! db))))
(define (legacy-mime-type stored-name)
(define lower (string-downcase stored-name))
(cond
((regexp-match? #px"[.]png$" lower) "image/png")
((regexp-match? #px"[.](jpg|jpeg)$" lower) "image/jpeg")
((regexp-match? #px"[.]gif$" lower) "image/gif")
((regexp-match? #px"[.]webp$" lower) "image/webp")
((regexp-match? #px"[.]pdf$" lower) "application/pdf")
((regexp-match? #px"[.]txt$" lower) "text/plain; charset=utf-8")
(else "application/octet-stream")))
(define (migrate-1->2! db config)
(query-exec db "ALTER TABLE attachments ADD COLUMN IF NOT EXISTS mime_type TEXT")
(query-exec db "ALTER TABLE attachments ADD COLUMN IF NOT EXISTS content BYTEA")
(define rows
(query-rows db
#<<SQL
SELECT a.id, p.slug, a.stored_name, a.content
FROM attachments a
JOIN pages p ON p.id = a.page_id
ORDER BY a.id
SQL
))
(for ((row (in-list rows)))
(define attachment-id (vector-ref row 0))
(define slug (vector-ref row 1))
(define stored-name (vector-ref row 2))
(define content (vector-ref row 3))
(unless (bytes? content)
(define path (build-path (uploads-directory config) slug stored-name))
(unless (file-exists? path)
(error 'migrate-database!
"schema 1 -> 2 cannot migrate attachment ~a: missing file ~a"
stored-name
(path->string path)))
(define bytes (file->bytes path))
(query-exec db
"UPDATE attachments SET content = $1, mime_type = $2, size = $3 WHERE id = $4"
bytes
(legacy-mime-type stored-name)
(bytes-length bytes)
attachment-id)))
(query-exec db "UPDATE attachments SET mime_type = 'application/octet-stream' WHERE mime_type IS NULL")
(query-exec db "ALTER TABLE attachments ALTER COLUMN mime_type SET NOT NULL")
(query-exec db "ALTER TABLE attachments ALTER COLUMN content SET NOT NULL")
(record-schema-version! db 2))
(define (replace-page-todos! db page-id markdown)
(query-exec db "DELETE FROM todo_items WHERE page_id = $1" page-id)
(for ((item (in-list (extract-todos markdown))))
(query-exec db
#<<SQL
INSERT INTO todo_items(page_id, item_number, line_number, text)
VALUES ($1, $2, $3, $4)
SQL
page-id
(hash-ref item 'number)
(hash-ref item 'line)
(hash-ref item 'text))))
(define (migrate-2->3! db)
(query-exec db
#<<SQL
CREATE TABLE IF NOT EXISTS todo_items (
id BIGSERIAL PRIMARY KEY,
page_id BIGINT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
item_number INTEGER NOT NULL,
line_number INTEGER NOT NULL,
text TEXT NOT NULL,
UNIQUE(page_id, item_number)
)
SQL
)
(query-exec db "CREATE INDEX IF NOT EXISTS todo_items_page_idx ON todo_items(page_id, item_number)")
(for ((row (in-list (query-rows db "SELECT id, markdown FROM pages WHERE archived = FALSE"))))
(replace-page-todos! db (vector-ref row 0) (vector-ref row 1)))
(record-schema-version! db 3))
(define (migrate-3->4! db)
(query-exec db
#<<SQL
CREATE TABLE IF NOT EXISTS bookmarks (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
page_id BIGINT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
section TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
PRIMARY KEY(user_id, page_id)
)
SQL
)
(query-exec db
"CREATE INDEX IF NOT EXISTS bookmarks_user_idx ON bookmarks(user_id, section, position, created_at)")
(record-schema-version! db 4))
(define (migrate-4->5! db)
(for ((row (in-list (query-rows db "SELECT id, markdown FROM pages WHERE archived = FALSE"))))
(replace-page-todos! db (vector-ref row 0) (vector-ref row 1)))
(record-schema-version! db 5))
(define (migrate-5->6! db)
(query-exec db
#<<SQL
CREATE TABLE IF NOT EXISTS attachment_references (
id BIGSERIAL PRIMARY KEY,
attachment_id BIGINT NOT NULL REFERENCES attachments(id) ON DELETE CASCADE,
page_id BIGINT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
page_version_id BIGINT REFERENCES page_versions(id) ON DELETE CASCADE,
current_reference BOOLEAN NOT NULL,
referenced_at BIGINT NOT NULL
)
SQL
)
(query-exec db
"CREATE INDEX IF NOT EXISTS attachment_references_attachment_idx ON attachment_references(attachment_id, current_reference, referenced_at DESC)")
(query-exec db
"CREATE INDEX IF NOT EXISTS attachment_references_page_idx ON attachment_references(page_id, current_reference)")
(rebuild-attachment-references! db)
(record-schema-version! db 6))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Bring a racket-wiki PostgreSQL database to the current schema.
; pre : db is a writable PostgreSQL connection and config identifies the
; data directory used by older racket-wiki versions.
; post : Every required migration has been applied in order and recorded.
; result : The resulting schema version.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (migrate-database! db config)
(call-with-transaction
db
(λ ()
(recognize-or-install-schema-1! db)
(define version (database-schema-version db))
(when (< version 1)
(error 'migrate-database! "unable to determine the existing wiki database schema"))
(when (= version 1)
(migrate-1->2! db config))
(define after-attachments (database-schema-version db))
(when (= after-attachments 2)
(migrate-2->3! db))
(define after-todos (database-schema-version db))
(when (= after-todos 3)
(migrate-3->4! db))
(define after-bookmarks (database-schema-version db))
(when (= after-bookmarks 4)
(migrate-4->5! db))
(define after-todo-reindex (database-schema-version db))
(when (= after-todo-reindex 5)
(migrate-5->6! db))
(define resulting-version (database-schema-version db))
(when (> resulting-version current-schema-version)
(error 'migrate-database!
"database schema ~a is newer than this racket-wiki supports (~a)"
resulting-version
current-schema-version))
resulting-version)))