- 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>
115 lines
3.3 KiB
Go
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
|
|
}
|