Add user model and SQLite storage

internal/storage/ provides:
- Open(path) to create or open the SQLite database with WAL journal,
  busy timeout, and foreign keys enabled
- Embedded migrations that create the users table on first run
- UserStore with Create, GetByID, GetByEmail, UpdateLastLogin, Delete
- Email normalisation (trim+lowercase) and uniqueness enforcement
  with ErrEmailTaken
- ErrNotFound on lookups and deletes
- UUIDv4 IDs auto-generated when caller leaves ID empty

Uses modernc.org/sqlite (pure-Go) so the binary stays CGO-free and
matches Dockerfile.dev's CGO_ENABLED=0.

Tests cover all CRUD operations, email uniqueness (case-insensitive),
WAL mode verification, and ErrNotFound paths.

Closes #8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:13:28 +02:00
co-authored by Claude Opus 4.7
parent b409519661
commit 0924e3cee9
5 changed files with 379 additions and 4 deletions
+50
View File
@@ -0,0 +1,50 @@
// Package storage provides the SQLite-backed persistence layer for
// multi-tenant user state. It owns the database connection, migrations,
// and CRUD repositories.
package storage
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// Open opens (or creates) the SQLite database at path, configures WAL
// mode and pragmas suitable for concurrent reads, and applies migrations.
func Open(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping sqlite: %w", err)
}
if err := migrate(db); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return db, nil
}
func migrate(db *sql.DB) error {
for _, stmt := range migrations {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("exec migration: %w\nstmt: %s", err, stmt)
}
}
return nil
}
var migrations = []string{
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
last_login_at INTEGER,
storage_path TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
}
+133
View File
@@ -0,0 +1,133 @@
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
)
// User represents a tenant of the librenotes service.
type User struct {
ID string
Email string
CreatedAt time.Time
LastLoginAt *time.Time
StoragePath string
}
// ErrNotFound indicates that a user lookup did not match any row.
var ErrNotFound = errors.New("user not found")
// ErrEmailTaken indicates that the given email already maps to a user.
var ErrEmailTaken = errors.New("email already registered")
// UserStore is the persistence interface for users. Implementations are
// expected to be safe for concurrent use.
type UserStore struct {
db *sql.DB
}
// NewUserStore wraps a database handle.
func NewUserStore(db *sql.DB) *UserStore { return &UserStore{db: db} }
// Create inserts a new user. The ID is generated as a UUIDv4 if empty.
// Email is normalised (trimmed + lowercased) before insertion. The
// StoragePath is preserved as given so the caller can decide on the
// filesystem layout.
func (s *UserStore) Create(ctx context.Context, u User) (User, error) {
if u.ID == "" {
u.ID = uuid.NewString()
}
u.Email = normaliseEmail(u.Email)
if u.Email == "" {
return User{}, fmt.Errorf("email required")
}
if u.StoragePath == "" {
return User{}, fmt.Errorf("storage_path required")
}
if u.CreatedAt.IsZero() {
u.CreatedAt = time.Now().UTC()
}
const q = `INSERT INTO users (id, email, created_at, last_login_at, storage_path)
VALUES (?, ?, ?, ?, ?)`
var lastLogin sql.NullInt64
if u.LastLoginAt != nil {
lastLogin = sql.NullInt64{Int64: u.LastLoginAt.Unix(), Valid: true}
}
_, err := s.db.ExecContext(ctx, q, u.ID, u.Email, u.CreatedAt.Unix(), lastLogin, u.StoragePath)
if err != nil {
if isUniqueErr(err) {
return User{}, ErrEmailTaken
}
return User{}, fmt.Errorf("insert user: %w", err)
}
return u, nil
}
// GetByID fetches a user by primary key.
func (s *UserStore) GetByID(ctx context.Context, id string) (User, error) {
return s.scanOne(ctx, `SELECT id, email, created_at, last_login_at, storage_path FROM users WHERE id = ?`, id)
}
// GetByEmail fetches a user by their (normalised) email.
func (s *UserStore) GetByEmail(ctx context.Context, email string) (User, error) {
return s.scanOne(ctx, `SELECT id, email, created_at, last_login_at, storage_path FROM users WHERE email = ?`, normaliseEmail(email))
}
// UpdateLastLogin records a successful login at the given instant.
func (s *UserStore) UpdateLastLogin(ctx context.Context, id string, at time.Time) error {
res, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, at.Unix(), id)
if err != nil {
return fmt.Errorf("update last_login: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// Delete removes a user row. Returns ErrNotFound if no row matched.
func (s *UserStore) Delete(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *UserStore) scanOne(ctx context.Context, q string, args ...any) (User, error) {
var u User
var created int64
var lastLogin sql.NullInt64
err := s.db.QueryRowContext(ctx, q, args...).Scan(&u.ID, &u.Email, &created, &lastLogin, &u.StoragePath)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrNotFound
}
if err != nil {
return User{}, fmt.Errorf("scan user: %w", err)
}
u.CreatedAt = time.Unix(created, 0).UTC()
if lastLogin.Valid {
t := time.Unix(lastLogin.Int64, 0).UTC()
u.LastLoginAt = &t
}
return u, nil
}
func normaliseEmail(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
func isUniqueErr(err error) bool {
// modernc.org/sqlite returns errors whose Error() text contains
// "UNIQUE constraint failed". This is stable across versions.
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}
+134
View File
@@ -0,0 +1,134 @@
package storage
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
)
func newTestStore(t *testing.T) *UserStore {
t.Helper()
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return NewUserStore(db)
}
func TestCreateAndGet(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
u, err := s.Create(ctx, User{Email: "Alice@example.COM", StoragePath: "data/alice"})
if err != nil {
t.Fatalf("create: %v", err)
}
if u.ID == "" {
t.Fatal("expected generated ID")
}
if u.Email != "alice@example.com" {
t.Errorf("email not normalised: %q", u.Email)
}
got, err := s.GetByID(ctx, u.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got.Email != u.Email || got.StoragePath != u.StoragePath {
t.Errorf("mismatch: %+v vs %+v", got, u)
}
got2, err := s.GetByEmail(ctx, " ALICE@example.com ")
if err != nil {
t.Fatalf("get by email: %v", err)
}
if got2.ID != u.ID {
t.Errorf("email lookup id mismatch")
}
}
func TestEmailUniqueness(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
_, err := s.Create(ctx, User{Email: "dup@example.com", StoragePath: "data/dup"})
if err != nil {
t.Fatalf("first create: %v", err)
}
_, err = s.Create(ctx, User{Email: "DUP@example.com", StoragePath: "data/dup2"})
if !errors.Is(err, ErrEmailTaken) {
t.Fatalf("expected ErrEmailTaken, got %v", err)
}
}
func TestUpdateLastLogin(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
u, err := s.Create(ctx, User{Email: "login@example.com", StoragePath: "data/login"})
if err != nil {
t.Fatal(err)
}
if u.LastLoginAt != nil {
t.Errorf("expected nil last_login on create")
}
now := time.Now().UTC().Truncate(time.Second)
if err := s.UpdateLastLogin(ctx, u.ID, now); err != nil {
t.Fatalf("update: %v", err)
}
got, _ := s.GetByID(ctx, u.ID)
if got.LastLoginAt == nil || !got.LastLoginAt.Equal(now) {
t.Errorf("last_login mismatch: %v vs %v", got.LastLoginAt, now)
}
}
func TestUpdateLastLoginMissing(t *testing.T) {
s := newTestStore(t)
err := s.UpdateLastLogin(context.Background(), "nope", time.Now())
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestDelete(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
u, _ := s.Create(ctx, User{Email: "del@example.com", StoragePath: "data/del"})
if err := s.Delete(ctx, u.ID); err != nil {
t.Fatalf("delete: %v", err)
}
_, err := s.GetByID(ctx, u.ID)
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound after delete, got %v", err)
}
if err := s.Delete(ctx, u.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound on second delete, got %v", err)
}
}
func TestGetMissing(t *testing.T) {
s := newTestStore(t)
if _, err := s.GetByID(context.Background(), "missing"); !errors.Is(err, ErrNotFound) {
t.Errorf("got %v", err)
}
if _, err := s.GetByEmail(context.Background(), "nobody@example.com"); !errors.Is(err, ErrNotFound) {
t.Errorf("got %v", err)
}
}
func TestWALMode(t *testing.T) {
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "wal.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
var mode string
if err := db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
t.Fatal(err)
}
if mode != "wal" {
t.Errorf("expected WAL mode, got %q", mode)
}
}