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:
@@ -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)`,
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user