Files
eng-pad-server/internal/db/migrations.go
Kyle Isom 9077117e74 Implement Phase 0+1: project setup, config, database, migrations
- Go module, Makefile, .golangci.yaml, .gitignore, example config
- TOML config loading with validation
- SQLite database with WAL, foreign keys, busy timeout
- Schema migrations: users, webauthn_credentials, notebooks, pages,
  strokes, share_links with indexes and cascading deletes
- 4 tests: open+migrate, idempotent, foreign keys, cascade delete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:47:38 -07:00

115 lines
3.3 KiB
Go

package db
import (
"database/sql"
"fmt"
)
var migrations = []struct {
name string
sql string
}{
{
name: "001_initial_schema",
sql: `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BLOB NOT NULL UNIQUE,
public_key BLOB NOT NULL,
name TEXT NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS notebooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
remote_id INTEGER NOT NULL,
title TEXT NOT NULL,
page_size TEXT NOT NULL,
synced_at INTEGER NOT NULL,
UNIQUE(user_id, remote_id)
);
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
notebook_id INTEGER NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
remote_id INTEGER NOT NULL,
page_number INTEGER NOT NULL,
UNIQUE(notebook_id, remote_id)
);
CREATE TABLE IF NOT EXISTS strokes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
pen_size REAL NOT NULL,
color INTEGER NOT NULL,
style TEXT NOT NULL DEFAULT 'plain',
point_data BLOB NOT NULL,
stroke_order INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS share_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
notebook_id INTEGER NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
expires_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY,
applied_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notebooks_user ON notebooks(user_id);
CREATE INDEX IF NOT EXISTS idx_pages_notebook ON pages(notebook_id);
CREATE INDEX IF NOT EXISTS idx_strokes_page ON strokes(page_id);
CREATE INDEX IF NOT EXISTS idx_share_links_token ON share_links(token);
CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id);
`,
},
}
func Migrate(database *sql.DB) error {
// Ensure schema_migrations table exists
_, err := database.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
for _, m := range migrations {
var count int
err := database.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE name = ?", m.name).Scan(&count)
if err != nil {
return fmt.Errorf("check migration %s: %w", m.name, err)
}
if count > 0 {
continue
}
if _, err := database.Exec(m.sql); err != nil {
return fmt.Errorf("apply migration %s: %w", m.name, err)
}
if _, err := database.Exec(
"INSERT INTO schema_migrations (name, applied_at) VALUES (?, strftime('%s','now'))",
m.name,
); err != nil {
return fmt.Errorf("record migration %s: %w", m.name, err)
}
}
return nil
}