simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit bf3b224f88c40039b3b6c70acc2851b2cc02e71d
parent 613fd402b13f2bc33c6597f04818120ed1efa503
Author: Silas Brack <silasbrack@gmail.com>
Date:   Fri, 12 Jun 2026 21:26:49 +0200

refactor: move migrations to SQL files, update README

Migrations now live as separate .sql files in migrations/ and are
embedded at compile time via include_str!. Readable SQL, testable
with sqlite3, compile-time guarantees.

Updated README to reflect current architecture (SQLite, no
TrailBase) with environment variables and migration docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
MREADME.md | 58++++++++++++++++++++++++++++++++++++++--------------------
Amigrations/0001_initial_schema.sql | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amigrations/0002_email_verification.sql | 2++
Msrc/migrations.rs | 175+++++++++++--------------------------------------------------------------------
4 files changed, 180 insertions(+), 172 deletions(-)

diff --git a/README.md b/README.md @@ -1,38 +1,56 @@ # Simple Web App -A link aggregator built with Rust, Axum, Askama, and Trailbase. +A link aggregator built with Rust, Axum, Askama, and SQLite. -## Principles +## Architecture -1. **Explicit over clever** — Code reads top-to-bottom. A new reader should understand what a function does without chasing through layers of indirection. -2. **Pure functions** — Isolate decision logic from I/O. -3. **Linear flow** — Handlers read as sequential steps: authenticate, fetch data, build view, respond. -4. **Minimize shared state** — Pass values explicitly. -5. **Minimize indirection** — No traits or abstractions "in case we need to swap later." +- **Web framework**: Axum (async Rust) +- **Templates**: Askama (compiled HTML templates) +- **Database**: SQLite via rusqlite (embedded, no separate service) +- **Frontend**: Datastar v1.0.2 for interactive updates (voting, search, real-time comments) +- **Auth**: argon2id password hashing, JWT tokens, email verification via SMTP ## Development ```bash nix develop -cargo run # start server -cargo watch -x run # auto-reload -trail run # trailbase (separate terminal) +cargo run # start server on :8000 +cargo watch -x run # auto-reload on changes ``` -## Scripts +### Environment variables -```bash -./scripts/generate-types.sh # regenerate Rust types from trailbase schema -TOKEN=$(trail user mint-token <email>) ./scripts/seed.sh # seed sample data +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8000` | Server port | +| `DB_PATH` | `data/app.db` | SQLite database path | +| `JWT_SECRET` | random | Secret for JWT tokens (random per restart if unset) | +| `SMTP_HOST` | `localhost` | SMTP server for verification emails | +| `SMTP_PORT` | `25` | SMTP port | +| `SMTP_FROM` | `noreply@silasbrack.com` | Sender email address | +| `SITE_URL` | `http://localhost:8000` | Base URL for email links | +| `DEBUG` | `true` | Debug mode | + +## Migrations + +Schema migrations live in `migrations/` as numbered SQL files and are embedded at compile time: + +``` +migrations/ + 0001_initial_schema.sql + 0002_email_verification.sql ``` +To add a new migration, create `migrations/NNNN_description.sql` and add an `include_str!` entry in `src/migrations.rs`. Migrations run automatically on startup in a single transaction. + ## Building ```bash -nix build # app binary -nix build .#trailbase # trailbase binary - -# WASM search guest (must be built separately) -cd guests/rust && cargo build --target wasm32-wasip2 --release -cp target/wasm32-wasip2/release/search_guest.wasm ../../traildepot/wasm/ +nix build # produces result/bin/simple-web-app ``` + +## Deployment + +Deployed on NixOS via the infrastructure flake. The app runs as a systemd service with `DynamicUser=true`, database on a Hetzner volume at `/mnt/volume-hel1-1/simple-web-app/data/app.db`. + +Static assets (CSS, JS) are embedded in the binary at compile time. diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql @@ -0,0 +1,117 @@ +CREATE TABLE IF NOT EXISTS user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + username TEXT UNIQUE NOT NULL, + about TEXT, + karma INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS story ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT, + title TEXT NOT NULL, + text TEXT NOT NULL DEFAULT '', + published TEXT NOT NULL DEFAULT (datetime('now')), + author TEXT, + language TEXT NOT NULL DEFAULT 'en', + created_by INTEGER REFERENCES user(id), + score INTEGER NOT NULL DEFAULT 0, + comment_count INTEGER NOT NULL DEFAULT 0, + domain TEXT +); + +CREATE TABLE IF NOT EXISTS comment ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + story_id INTEGER NOT NULL REFERENCES story(id), + parent_id INTEGER REFERENCES comment(id), + path TEXT NOT NULL DEFAULT '', + depth INTEGER NOT NULL DEFAULT 0, + text TEXT NOT NULL DEFAULT '', + score INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + created_by INTEGER NOT NULL REFERENCES user(id) +); + +CREATE TABLE IF NOT EXISTS vote ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES user(id), + target_type INTEGER NOT NULL, + target_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, target_type, target_id) +); + +CREATE TABLE IF NOT EXISTS category ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT +); + +CREATE TABLE IF NOT EXISTS story_category ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + news_item_id INTEGER NOT NULL REFERENCES story(id), + category_id INTEGER NOT NULL REFERENCES category(id) +); + +CREATE VIRTUAL TABLE IF NOT EXISTS story_fts USING fts5( + title, text, + content='story', + content_rowid='id', + tokenize='porter unicode61' +); + +-- Indices +CREATE INDEX IF NOT EXISTS idx_story_published ON story(published DESC); +CREATE INDEX IF NOT EXISTS idx_story_score ON story(score DESC); +CREATE INDEX IF NOT EXISTS idx_story_created_by ON story(created_by); +CREATE INDEX IF NOT EXISTS idx_comment_story_id ON comment(story_id); +CREATE INDEX IF NOT EXISTS idx_comment_parent_id ON comment(parent_id); +CREATE INDEX IF NOT EXISTS idx_comment_created_by ON comment(created_by); +CREATE INDEX IF NOT EXISTS idx_vote_user_target ON vote(user_id, target_type, target_id); +CREATE INDEX IF NOT EXISTS idx_story_category_news_item ON story_category(news_item_id); +CREATE INDEX IF NOT EXISTS idx_story_category_category ON story_category(category_id); + +-- FTS triggers +CREATE TRIGGER IF NOT EXISTS story_fts_insert AFTER INSERT ON story BEGIN + INSERT INTO story_fts(rowid, title, text) VALUES (new.id, new.title, new.text); +END; + +CREATE TRIGGER IF NOT EXISTS story_fts_update AFTER UPDATE ON story BEGIN + INSERT INTO story_fts(story_fts, rowid, title, text) VALUES ('delete', old.id, old.title, old.text); + INSERT INTO story_fts(rowid, title, text) VALUES (new.id, new.title, new.text); +END; + +CREATE TRIGGER IF NOT EXISTS story_fts_delete AFTER DELETE ON story BEGIN + INSERT INTO story_fts(story_fts, rowid, title, text) VALUES ('delete', old.id, old.title, old.text); +END; + +-- Comment count triggers +CREATE TRIGGER IF NOT EXISTS comment_count_insert AFTER INSERT ON comment BEGIN + UPDATE story SET comment_count = comment_count + 1 WHERE id = new.story_id; +END; + +CREATE TRIGGER IF NOT EXISTS comment_count_delete AFTER DELETE ON comment BEGIN + UPDATE story SET comment_count = comment_count - 1 WHERE id = old.story_id; +END; + +-- Vote score/karma triggers +CREATE TRIGGER IF NOT EXISTS vote_insert_story AFTER INSERT ON vote WHEN new.target_type = 1 BEGIN + UPDATE story SET score = score + 1 WHERE id = new.target_id; + UPDATE user SET karma = karma + 1 WHERE id = (SELECT created_by FROM story WHERE id = new.target_id); +END; + +CREATE TRIGGER IF NOT EXISTS vote_delete_story AFTER DELETE ON vote WHEN old.target_type = 1 BEGIN + UPDATE story SET score = score - 1 WHERE id = old.target_id; + UPDATE user SET karma = karma - 1 WHERE id = (SELECT created_by FROM story WHERE id = old.target_id); +END; + +CREATE TRIGGER IF NOT EXISTS vote_insert_comment AFTER INSERT ON vote WHEN new.target_type = 2 BEGIN + UPDATE comment SET score = score + 1 WHERE id = new.target_id; + UPDATE user SET karma = karma + 1 WHERE id = (SELECT created_by FROM comment WHERE id = new.target_id); +END; + +CREATE TRIGGER IF NOT EXISTS vote_delete_comment AFTER DELETE ON vote WHEN old.target_type = 2 BEGIN + UPDATE comment SET score = score - 1 WHERE id = old.target_id; + UPDATE user SET karma = karma - 1 WHERE id = (SELECT created_by FROM comment WHERE id = old.target_id); +END; diff --git a/migrations/0002_email_verification.sql b/migrations/0002_email_verification.sql @@ -0,0 +1,2 @@ +ALTER TABLE user ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0; +ALTER TABLE user ADD COLUMN verification_token TEXT; diff --git a/src/migrations.rs b/src/migrations.rs @@ -1,140 +1,16 @@ use rusqlite::Connection; -/// Each migration is a SQL string. Migration 0 is the initial schema. -/// Migrations are applied in order, and the current version is tracked -/// in the `migration_version` table. const MIGRATIONS: &[&str] = &[ - // Migration 0: Initial schema - r#" - CREATE TABLE IF NOT EXISTS user ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - username TEXT UNIQUE NOT NULL, - about TEXT, - karma INTEGER DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')) - ); - - CREATE TABLE IF NOT EXISTS story ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - url TEXT, - title TEXT NOT NULL, - text TEXT NOT NULL DEFAULT '', - published TEXT NOT NULL DEFAULT (datetime('now')), - author TEXT, - language TEXT NOT NULL DEFAULT 'en', - created_by INTEGER REFERENCES user(id), - score INTEGER NOT NULL DEFAULT 0, - comment_count INTEGER NOT NULL DEFAULT 0, - domain TEXT - ); - - CREATE TABLE IF NOT EXISTS comment ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - story_id INTEGER NOT NULL REFERENCES story(id), - parent_id INTEGER REFERENCES comment(id), - path TEXT NOT NULL DEFAULT '', - depth INTEGER NOT NULL DEFAULT 0, - text TEXT NOT NULL DEFAULT '', - score INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - created_by INTEGER NOT NULL REFERENCES user(id) - ); - - CREATE TABLE IF NOT EXISTS vote ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES user(id), - target_type INTEGER NOT NULL, - target_id INTEGER NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(user_id, target_type, target_id) - ); - - CREATE TABLE IF NOT EXISTS category ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT - ); - - CREATE TABLE IF NOT EXISTS story_category ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - news_item_id INTEGER NOT NULL REFERENCES story(id), - category_id INTEGER NOT NULL REFERENCES category(id) - ); - - CREATE VIRTUAL TABLE IF NOT EXISTS story_fts USING fts5( - title, text, - content='story', - content_rowid='id', - tokenize='porter unicode61' - ); - - CREATE INDEX IF NOT EXISTS idx_story_published ON story(published DESC); - CREATE INDEX IF NOT EXISTS idx_story_score ON story(score DESC); - CREATE INDEX IF NOT EXISTS idx_story_created_by ON story(created_by); - CREATE INDEX IF NOT EXISTS idx_comment_story_id ON comment(story_id); - CREATE INDEX IF NOT EXISTS idx_comment_parent_id ON comment(parent_id); - CREATE INDEX IF NOT EXISTS idx_comment_created_by ON comment(created_by); - CREATE INDEX IF NOT EXISTS idx_vote_user_target ON vote(user_id, target_type, target_id); - CREATE INDEX IF NOT EXISTS idx_story_category_news_item ON story_category(news_item_id); - CREATE INDEX IF NOT EXISTS idx_story_category_category ON story_category(category_id); - - CREATE TRIGGER IF NOT EXISTS story_fts_insert AFTER INSERT ON story BEGIN - INSERT INTO story_fts(rowid, title, text) VALUES (new.id, new.title, new.text); - END; - - CREATE TRIGGER IF NOT EXISTS story_fts_update AFTER UPDATE ON story BEGIN - INSERT INTO story_fts(story_fts, rowid, title, text) VALUES ('delete', old.id, old.title, old.text); - INSERT INTO story_fts(rowid, title, text) VALUES (new.id, new.title, new.text); - END; - - CREATE TRIGGER IF NOT EXISTS story_fts_delete AFTER DELETE ON story BEGIN - INSERT INTO story_fts(story_fts, rowid, title, text) VALUES ('delete', old.id, old.title, old.text); - END; - - CREATE TRIGGER IF NOT EXISTS comment_count_insert AFTER INSERT ON comment BEGIN - UPDATE story SET comment_count = comment_count + 1 WHERE id = new.story_id; - END; - - CREATE TRIGGER IF NOT EXISTS comment_count_delete AFTER DELETE ON comment BEGIN - UPDATE story SET comment_count = comment_count - 1 WHERE id = old.story_id; - END; - - CREATE TRIGGER IF NOT EXISTS vote_insert_story AFTER INSERT ON vote WHEN new.target_type = 1 BEGIN - UPDATE story SET score = score + 1 WHERE id = new.target_id; - UPDATE user SET karma = karma + 1 WHERE id = (SELECT created_by FROM story WHERE id = new.target_id); - END; - - CREATE TRIGGER IF NOT EXISTS vote_delete_story AFTER DELETE ON vote WHEN old.target_type = 1 BEGIN - UPDATE story SET score = score - 1 WHERE id = old.target_id; - UPDATE user SET karma = karma - 1 WHERE id = (SELECT created_by FROM story WHERE id = old.target_id); - END; - - CREATE TRIGGER IF NOT EXISTS vote_insert_comment AFTER INSERT ON vote WHEN new.target_type = 2 BEGIN - UPDATE comment SET score = score + 1 WHERE id = new.target_id; - UPDATE user SET karma = karma + 1 WHERE id = (SELECT created_by FROM comment WHERE id = new.target_id); - END; - - CREATE TRIGGER IF NOT EXISTS vote_delete_comment AFTER DELETE ON vote WHEN old.target_type = 2 BEGIN - UPDATE comment SET score = score - 1 WHERE id = old.target_id; - UPDATE user SET karma = karma - 1 WHERE id = (SELECT created_by FROM comment WHERE id = old.target_id); - END; - "#, - - // Migration 1: Add email verification columns - r#" - ALTER TABLE user ADD COLUMN email_verified INTEGER NOT NULL DEFAULT 0; - ALTER TABLE user ADD COLUMN verification_token TEXT; - "#, + include_str!("../migrations/0001_initial_schema.sql"), + include_str!("../migrations/0002_email_verification.sql"), ]; pub fn run(conn: &Connection) -> Result<(), String> { - // Create migration_version table if it doesn't exist conn.execute_batch( - "CREATE TABLE IF NOT EXISTS migration_version (version INTEGER NOT NULL);" - ).map_err(|e| format!("Failed to create migration_version table: {}", e))?; + "CREATE TABLE IF NOT EXISTS migration_version (version INTEGER NOT NULL);", + ) + .map_err(|e| format!("Failed to create migration_version table: {e}"))?; - // Get current version let current_version: i64 = conn .query_row( "SELECT COALESCE(MAX(version), 0) FROM migration_version", @@ -146,7 +22,6 @@ pub fn run(conn: &Connection) -> Result<(), String> { let total = MIGRATIONS.len() as i64; if current_version >= total { - eprintln!("Database is up to date (version {})", current_version); return Ok(()); } @@ -157,40 +32,36 @@ pub fn run(conn: &Connection) -> Result<(), String> { current_version ); - // Run all pending migrations in a single transaction conn.execute_batch("BEGIN TRANSACTION;") - .map_err(|e| format!("Failed to begin transaction: {}", e))?; + .map_err(|e| format!("Failed to begin transaction: {e}"))?; for i in current_version..total { eprintln!(" Applying migration {}...", i); - conn.execute_batch(MIGRATIONS[i as usize]) - .map_err(|e| { - let _ = conn.execute_batch("ROLLBACK;"); - format!("Migration {} failed: {}", i, e) - })?; + conn.execute_batch(MIGRATIONS[i as usize]).map_err(|e| { + let _ = conn.execute_batch("ROLLBACK;"); + format!("Migration {} failed: {}", i, e) + })?; } - // Update version - if current_version == 0 { - // Check if there are any rows - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM migration_version", [], |row| row.get(0)) - .map_err(|e| format!("Failed to count migration_version rows: {}", e))?; + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM migration_version", [], |row| { + row.get(0) + }) + .map_err(|e| format!("Failed to count migration_version rows: {e}"))?; - if count == 0 { - conn.execute("INSERT INTO migration_version (version) VALUES (?1)", [total]) - .map_err(|e| format!("Failed to insert migration version: {}", e))?; - } else { - conn.execute("UPDATE migration_version SET version = ?1", [total]) - .map_err(|e| format!("Failed to update migration version: {}", e))?; - } + if count == 0 { + conn.execute( + "INSERT INTO migration_version (version) VALUES (?1)", + [total], + ) + .map_err(|e| format!("Failed to insert migration version: {e}"))?; } else { conn.execute("UPDATE migration_version SET version = ?1", [total]) - .map_err(|e| format!("Failed to update migration version: {}", e))?; + .map_err(|e| format!("Failed to update migration version: {e}"))?; } conn.execute_batch("COMMIT;") - .map_err(|e| format!("Failed to commit transaction: {}", e))?; + .map_err(|e| format!("Failed to commit transaction: {e}"))?; eprintln!("Migrations complete. Database is now at version {}", total); Ok(())