feat: initial sqlite store

This commit is contained in:
juancwu 2026-05-06 00:16:39 +00:00
commit 9918c9918f
10 changed files with 560 additions and 6 deletions

View file

@ -0,0 +1,38 @@
package sqlite
import (
"context"
"database/sql"
"embed"
"fmt"
"git.juancwu.dev/juancwu/pase/store"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Migrate applies all pending migrations to db using the SQLite dialect.
// It is safe to call repeatedly; already-applied migrations are skipped.
//
// Run from a single instance at startup. There is no advisory locking; if you
// need it, gate the call behind your own coordination primitive.
func Migrate(ctx context.Context, db *sql.DB) error {
migrations, err := store.LoadMigrations(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("pase/sqlite: load migrations: %w", err)
}
m := &store.Migrator{
DB: db,
Dialect: store.SQLiteDialect{},
Migrations: migrations,
// SQLite's TEXT timestamp convention. INTEGER PK is implicit ROWID alias.
CreateTableSQL: `CREATE TABLE IF NOT EXISTS pase_schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
)`,
}
return m.Migrate(ctx)
}

94
store/sqlite/store.go Normal file
View file

@ -0,0 +1,94 @@
// Package sqlite provides a SQLite-backed implementation of store.Store.
// Tested with "modernc.org/sqlite" other sqlite drivers may yield different results.
package sqlite
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"git.juancwu.dev/juancwu/pase/store"
)
// Store is a SQLite-backed implementation of store.Store.
//
// All queries are written with ? placeholders and rebound through the dialect
// at construction time. Every method is intended to be a single round-trip;
// methods that need atomicity (e.g. ConsumeToken) use a single statement or
// an explicit transaction — never read-modify-write through Go.
type Store struct {
db *sql.DB
dialect store.Dialect
// Pre-rebound query strings. One field per query keeps the SQL text out
// of method bodies and makes it easy to audit by reading the struct.
q store.Queries
}
// NewStore wraps an existing *sql.DB. It does not own the connection pool;
// the caller is responsible for opening, configuring, and closing it.
//
// Foreign key enforcement is required; configure it on the connection (e.g.
// via DSN `?_pragma=foreign_keys(1)` for modernc.org/sqlite, or
// `?_fk=1` for mattn/go-sqlite3) before passing the *sql.DB in.
func NewStore(db *sql.DB) *Store {
d := store.SQLiteDialect{}
s := &Store{db: db, dialect: d}
s.q = store.CanonicalQueries.Rebind(d)
return s
}
// CreateUser inserts a user row. Timestamps will be overwritten if they are set.
func (s *Store) CreateUser(ctx context.Context, u *store.User) error {
now := time.Now()
u.CreatedAt = now
u.UpdatedAt = now
_, err := s.db.ExecContext(ctx, s.q.CreateUser,
u.ID, u.Email, u.EmailVerifiedAt,
u.Username, u.UsernameNormalized,
u.DisplayName, u.ProfileImageURL,
u.Status, u.StatusReason,
u.StatusChangedAt, u.StatusExpiresAt,
u.FailedLoginCount, u.LastFailedLoginAt,
u.CreatedAt, u.UpdatedAt,
)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "pase_users.email") {
return fmt.Errorf("pase/sqlite: create user: %w", store.ErrEmailAlreadyExists)
}
if strings.Contains(errStr, "pase_users.username_normalized") {
return fmt.Errorf("pase/sqlite: create user: %w", store.ErrUsernameAlreadyExists)
}
return fmt.Errorf("pase/sqlite: create user: %w", err)
}
return nil
}
// GetUserByID returns the user with the given id.
func (s *Store) GetUserByID(ctx context.Context, id string) (*store.User, error) {
row := s.db.QueryRowContext(ctx, s.q.GetUserByID, id)
var u store.User
err := row.Scan(
&u.ID, &u.Email, &u.EmailVerifiedAt,
&u.Username, &u.UsernameNormalized, &u.DisplayName, &u.ProfileImageURL,
&u.Status, &u.StatusReason,
&u.StatusChangedAt, &u.StatusExpiresAt,
&u.FailedLoginCount, &u.LastFailedLoginAt,
&u.CreatedAt, &u.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("pase/sqlite: get user by id: %w", store.ErrUserNotFound)
}
return nil, fmt.Errorf("pase/sqlite: get user by id: %w", err)
}
return &u, nil
}

View file

@ -0,0 +1,71 @@
package sqlite_test
import (
"context"
"database/sql"
"testing"
"git.juancwu.dev/juancwu/pase/store/sqlite"
"git.juancwu.dev/juancwu/pase/store/storetest"
// modernc.org/sqlite registers the "sqlite" driver. Pure Go, no CGO.
// Imported only by tests; the sqlite package itself stays driver-agnostic.
_ "modernc.org/sqlite"
)
// newSQLiteStore opens a fresh in-memory SQLite, applies migrations, and
// returns a Store ready to be exercised by the storetest suite. Each call
// produces an isolated database — no cross-test pollution.
//
// DSN notes:
// - file::memory: form is required so the driver parses query params.
// Plain ":memory:?..." would silently ignore them.
// - _pragma=foreign_keys(1) enables ON DELETE CASCADE on this connection.
// - _time_format=sqlite makes the driver write time.Time as
// "2006-01-02 15:04:05.999999999-07:00", which Go's stdlib can read back
// via sql.NullTime.Scan.
func newSQLiteStore(t *testing.T) storetest.SuiteStore {
t.Helper()
db, err := sql.Open("sqlite", "file::memory:?_pragma=foreign_keys(1)&_time_format=sqlite")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
// In-memory databases are per-connection by default. Pin the pool to a
// single connection so subsequent calls hit the same database.
db.SetMaxOpenConns(1)
if err := sqlite.Migrate(context.Background(), db); err != nil {
t.Fatalf("migrate: %v", err)
}
return sqlite.NewStore(db)
}
// TestSQLiteStore runs the full Store contract suite against the SQLite
// implementation. Add new dialect-specific tests below; they should be
// rare — anything testable as a contract belongs in storetest.
func TestSQLiteStore(t *testing.T) {
storetest.RunSuite(t, newSQLiteStore)
}
// TestMigrate_idempotent stays here because migrations are dialect-specific
// (per-package embed.FS) and not part of the Store interface contract.
func TestMigrate_idempotent(t *testing.T) {
db, err := sql.Open("sqlite", "file::memory:?_pragma=foreign_keys(1)&_time_format=sqlite")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
db.SetMaxOpenConns(1)
ctx := context.Background()
if err := sqlite.Migrate(ctx, db); err != nil {
t.Fatalf("first Migrate: %v", err)
}
if err := sqlite.Migrate(ctx, db); err != nil {
t.Fatalf("second Migrate should succeed, got: %v", err)
}
}