feat(metadata): SQLite backend, storage reconciliation and local defaults

- pluggable per-domain Repository interfaces; PostgreSQL and SQLite impls
- SQLite on modernc.org/sqlite (WAL, busy_timeout, foreign_keys, immediate tx)
- separate migrations/sqlite and db.MigrateSQLite; store.NewPostgres/NewSQLite
- cmd/reconcile compares object storage with the journal (report, then --delete)
- storage.Backend.List and photos.Repository.AllStorageKeys
- DB_BACKEND/SQLITE_PATH and STORAGE_BACKEND/FS_ROOT selectable; local defaults
  (sqlite ./galo.db, fs ./data)
- Makefile reconcile/reconcile-delete; docs and .env.example updated
This commit is contained in:
iu
2026-10-11 10:08:31 +03:00
parent ec8d907565
commit 176a8d8ced
69 changed files with 4174 additions and 95 deletions
+18
View File
@@ -0,0 +1,18 @@
package albums
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the albums domain.
type Repository interface {
Create(ctx context.Context, a *models.Album) error
OwnerID(ctx context.Context, id string) (string, error)
GetByID(ctx context.Context, id string) (*models.Album, error)
ListForUser(ctx context.Context, userID string) ([]models.Album, error)
Update(ctx context.Context, a *models.Album) error
Delete(ctx context.Context, id string) error
TrashPhotosByAlbum(ctx context.Context, albumID string) error
}
+2 -2
View File
@@ -18,13 +18,13 @@ type MemberLookup interface {
}
type Service struct {
store *Store
store Repository
members MemberLookup
hub *sse.Hub
storage storage.Backend
}
func NewService(store *Store, members MemberLookup, hub *sse.Hub, storage storage.Backend) *Service {
func NewService(store Repository, members MemberLookup, hub *sse.Hub, storage storage.Backend) *Service {
return &Service{store: store, members: members, hub: hub, storage: storage}
}
+142
View File
@@ -0,0 +1,142 @@
package albums
import (
"context"
"database/sql"
"errors"
"time"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) Create(ctx context.Context, a *models.Album) error {
var createdAt, updatedAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO albums (id, owner_id, title, description, cover_photo_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING created_at, updated_at`,
a.ID, a.OwnerID, a.Title, a.Description, a.CoverPhotoID,
).Scan(&createdAt, &updatedAt)
if err != nil {
return err
}
a.CreatedAt = sqliteutil.ParseTime(createdAt)
a.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return nil
}
func (s *SQLiteStore) OwnerID(ctx context.Context, id string) (string, error) {
var ownerID string
err := s.db.QueryRowContext(ctx, `SELECT owner_id FROM albums WHERE id = $1`, id).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return "", err
}
return ownerID, nil
}
const sqliteAlbumColumns = `a.id, a.owner_id, a.title, a.description, a.cover_photo_id,
a.created_at, a.updated_at, COALESCE(co.storage_key, ''),
(SELECT count(*) FROM photos WHERE album_id = a.id),
(SELECT count(*) FROM album_members WHERE album_id = a.id)`
const sqliteAlbumJoins = `
LEFT JOIN photo_objects cpo ON cpo.photo_id = a.cover_photo_id AND cpo.role = 'thumb'
LEFT JOIN objects co ON co.id = cpo.object_id`
func scanSQLiteAlbum(scan func(...any) error) (*models.Album, error) {
var a models.Album
var cover sql.NullString
var createdAt, updatedAt string
if err := scan(&a.ID, &a.OwnerID, &a.Title, &a.Description, &cover, &createdAt, &updatedAt,
&a.CoverThumbKey, &a.PhotosCount, &a.MembersCount); err != nil {
return nil, err
}
if cover.Valid {
a.CoverPhotoID = &cover.String
}
a.CreatedAt = sqliteutil.ParseTime(createdAt)
a.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return &a, nil
}
func (s *SQLiteStore) GetByID(ctx context.Context, id string) (*models.Album, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+sqliteAlbumColumns+` FROM albums a`+sqliteAlbumJoins+` WHERE a.id = $1`, id)
a, err := scanSQLiteAlbum(row.Scan)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return a, nil
}
func (s *SQLiteStore) ListForUser(ctx context.Context, userID string) ([]models.Album, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+sqliteAlbumColumns+` FROM albums a
LEFT JOIN album_members m ON m.album_id = a.id AND m.user_id = $1`+sqliteAlbumJoins+`
WHERE a.owner_id = $1 OR m.user_id IS NOT NULL
ORDER BY a.created_at DESC`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Album{}
for rows.Next() {
a, err := scanSQLiteAlbum(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
func (s *SQLiteStore) Update(ctx context.Context, a *models.Album) error {
var updatedAt string
err := s.db.QueryRowContext(ctx,
`UPDATE albums
SET title = $2, description = $3, cover_photo_id = $4, updated_at = $5
WHERE id = $1
RETURNING updated_at`,
a.ID, a.Title, a.Description, a.CoverPhotoID, sqliteutil.FormatTime(time.Now().UTC()),
).Scan(&updatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
a.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return nil
}
func (s *SQLiteStore) Delete(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM albums WHERE id = $1`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) TrashPhotosByAlbum(ctx context.Context, albumID string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE photos SET deleted_at = $2 WHERE album_id = $1 AND deleted_at IS NULL`,
albumID, sqliteutil.FormatTime(time.Now().UTC()))
return err
}
+18 -24
View File
@@ -9,7 +9,6 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
assets "shared-album"
"shared-album/internal/albums"
@@ -23,16 +22,17 @@ import (
"shared-album/internal/social"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
type Server struct {
cfg *config.Config
pool *pgxpool.Pool
repos store.Repos
ping func(ctx context.Context) error
storage storage.Backend
hub *sse.Hub
albumsStore *albums.Store
albums *albums.Service
albums *albums.Service
auth *auth.Service
@@ -48,36 +48,30 @@ type Server struct {
share *sharelinks.Service
}
func New(cfg *config.Config, pool *pgxpool.Pool, st storage.Backend, hub *sse.Hub) *Server {
authStore := auth.NewStore(pool)
// New wires the domain services over the selected metadata repositories. ping
// reports the health of the metadata backend so health stays backend-agnostic.
func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) error, st storage.Backend, hub *sse.Hub) *Server {
tm := auth.NewTokenManager(cfg.Auth.JWTSecret, cfg.Auth.AccessTTL, cfg.Auth.RefreshTTL)
authSvc := auth.NewService(authStore, tm)
authSvc := auth.NewService(repos.Auth, tm)
albumsStore := albums.NewStore(pool)
membersStore := members.NewStore(pool)
notifSvc := notifications.NewService(repos.Notifications, hub)
notifStore := notifications.NewStore(pool)
notifSvc := notifications.NewService(notifStore, hub)
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, st)
membersSvc := members.NewService(repos.Members, repos.Albums, hub, notifSvc)
albumsSvc := albums.NewService(albumsStore, membersStore, hub, st)
membersSvc := members.NewService(membersStore, albumsStore, hub, notifSvc)
photosStore := photos.NewStore(pool)
photosSvc := photos.NewService(photosStore, albumsSvc, st, hub)
photosSvc := photos.NewService(repos.Photos, albumsSvc, st, hub)
photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL)
socialStore := social.NewStore(pool)
socialSvc := social.NewService(socialStore, albumsSvc, photosStore, hub, notifSvc)
socialSvc := social.NewService(repos.Social, albumsSvc, repos.Photos, hub, notifSvc)
shareStore := sharelinks.NewStore(pool)
shareSvc := sharelinks.NewService(shareStore, albumsSvc)
shareSvc := sharelinks.NewService(repos.ShareLinks, albumsSvc)
return &Server{
cfg: cfg,
pool: pool,
repos: repos,
ping: ping,
storage: st,
hub: hub,
albumsStore: albumsStore,
albums: albumsSvc,
auth: authSvc,
membersSvc: membersSvc,
@@ -183,7 +177,7 @@ func (s *Server) Router() *gin.Engine {
func (s *Server) health(c *gin.Context) {
ctx := c.Request.Context()
dbStatus := "ok"
if err := s.pool.Ping(ctx); err != nil {
if err := s.ping(ctx); err != nil {
dbStatus = "error"
}
storageLabel := s.cfg.Storage.Backend
@@ -243,7 +237,7 @@ func (s *Server) shareAlbum(c *gin.Context) {
httpx.RespondError(c, err)
return
}
album, err := s.albumsStore.GetByID(c.Request.Context(), link.AlbumID)
album, err := s.repos.Albums.GetByID(c.Request.Context(), link.AlbumID)
if err != nil {
httpx.RespondError(c, httpx.NotFound("album_not_found", "album not found"))
return
+2 -12
View File
@@ -9,7 +9,6 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/config"
"shared-album/internal/storage"
@@ -24,23 +23,14 @@ func mustSigner(t *testing.T) *storage.Signer {
return signer
}
func lazyPool(t *testing.T) *pgxpool.Pool {
t.Helper()
pool, err := pgxpool.New(context.Background(), "postgres://u:p@127.0.0.1:1/x?sslmode=disable")
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
return pool
}
func TestHealthStorageLabel(t *testing.T) {
gin.SetMode(gin.TestMode)
fs, err := storage.NewFS(t.TempDir(), mustSigner(t))
if err != nil {
t.Fatalf("fs: %v", err)
}
srv := &Server{cfg: &config.Config{}, pool: lazyPool(t), storage: fs}
ok := func(context.Context) error { return nil }
srv := &Server{cfg: &config.Config{}, ping: ok, storage: fs}
call := func() map[string]any {
rec := httptest.NewRecorder()
+3 -1
View File
@@ -15,6 +15,7 @@ import (
"shared-album/internal/config"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) {
@@ -28,7 +29,8 @@ func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) {
if err != nil {
t.Fatalf("new fs: %v", err)
}
srv := New(&config.Config{}, nil, fs, sse.NewHub(1))
noop := func(context.Context) error { return nil }
srv := New(&config.Config{}, store.Repos{}, noop, fs, sse.NewHub(1))
return fs, srv.Router()
}
+2 -1
View File
@@ -27,6 +27,7 @@ import (
"shared-album/internal/sharelinks"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
func integrationPool(t *testing.T) *pgxpool.Pool {
@@ -70,7 +71,7 @@ func runShareFlow(t *testing.T, backend storage.Backend, verify func(t *testing.
gin.SetMode(gin.TestMode)
hub := sse.NewHub(1)
srv := New(&config.Config{}, pool, backend, hub)
srv := New(&config.Config{}, store.NewPostgres(pool), pool.Ping, backend, hub)
router := srv.Router()
authStore := auth.NewStore(pool)
+19
View File
@@ -0,0 +1,19 @@
package auth
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the auth domain.
type Repository interface {
CreateUser(ctx context.Context, u *models.User) error
GetUserByEmail(ctx context.Context, email string) (*models.User, error)
GetUserByID(ctx context.Context, id string) (*models.User, error)
UpdatePreferences(ctx context.Context, userID string, raw []byte) error
CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error
GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error)
RevokeRefreshToken(ctx context.Context, id string) error
RevokeAllUserTokens(ctx context.Context, userID string) error
}
+2 -2
View File
@@ -18,11 +18,11 @@ type TokenPair struct {
}
type Service struct {
store *Store
store Repository
tm *TokenManager
}
func NewService(store *Store, tm *TokenManager) *Service {
func NewService(store Repository, tm *TokenManager) *Service {
return &Service{store: store, tm: tm}
}
+111
View File
@@ -0,0 +1,111 @@
package auth
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) CreateUser(ctx context.Context, u *models.User) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO users (id, email, password_hash, name, avatar_url)
VALUES ($1, $2, $3, $4, $5)`,
u.ID, u.Email, u.PasswordHash, u.Name, nullIfEmpty(u.AvatarURL))
return err
}
func (s *SQLiteStore) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
return s.scanUser(s.db.QueryRowContext(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
COALESCE(preferences, '{}'), created_at
FROM users WHERE lower(email) = lower($1)`, email))
}
func (s *SQLiteStore) GetUserByID(ctx context.Context, id string) (*models.User, error) {
return s.scanUser(s.db.QueryRowContext(ctx,
`SELECT id, email, password_hash, name, COALESCE(avatar_url, ''),
COALESCE(preferences, '{}'), created_at
FROM users WHERE id = $1`, id))
}
func (s *SQLiteStore) scanUser(row *sql.Row) (*models.User, error) {
var u models.User
var prefs, createdAt string
if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Name, &u.AvatarURL, &prefs, &createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
u.CreatedAt = sqliteutil.ParseTime(createdAt)
if prefs != "" && prefs != "null" {
_ = json.Unmarshal([]byte(prefs), &u.Preferences)
}
return &u, nil
}
func (s *SQLiteStore) UpdatePreferences(ctx context.Context, userID string, raw []byte) error {
res, err := s.db.ExecContext(ctx,
`UPDATE users SET preferences = $2 WHERE id = $1`, userID, string(raw))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) CreateRefreshToken(ctx context.Context, t *models.RefreshToken) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at)
VALUES ($1, $2, $3, $4)`,
t.ID, t.UserID, t.TokenHash, sqliteutil.FormatTime(t.ExpiresAt))
return err
}
func (s *SQLiteStore) GetRefreshToken(ctx context.Context, hash string) (*models.RefreshToken, error) {
var t models.RefreshToken
var expiresAt, createdAt string
var revokedAt sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT id, user_id, token_hash, expires_at, revoked_at, created_at
FROM refresh_tokens WHERE token_hash = $1`, hash).
Scan(&t.ID, &t.UserID, &t.TokenHash, &expiresAt, &revokedAt, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
t.ExpiresAt = sqliteutil.ParseTime(expiresAt)
t.CreatedAt = sqliteutil.ParseTime(createdAt)
t.RevokedAt = sqliteutil.ParseNullTime(revokedAt)
return &t, nil
}
func (s *SQLiteStore) RevokeRefreshToken(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE refresh_tokens SET revoked_at = $2 WHERE id = $1 AND revoked_at IS NULL`,
id, sqliteutil.FormatTime(time.Now()))
return err
}
func (s *SQLiteStore) RevokeAllUserTokens(ctx context.Context, userID string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE refresh_tokens SET revoked_at = $2 WHERE user_id = $1 AND revoked_at IS NULL`,
userID, sqliteutil.FormatTime(time.Now()))
return err
}
+24 -4
View File
@@ -59,10 +59,16 @@ type Storage struct {
URLTTL time.Duration
}
type Database struct {
Backend string
SQLitePath string
}
type Config struct {
Postgres Postgres
S3 S3
Storage Storage
Database Database
Auth Auth
Server Server
SSE SSE
@@ -138,11 +144,15 @@ func Load() (*Config, error) {
UsePathStyle: pathStyle,
},
Storage: Storage{
Backend: getEnv("STORAGE_BACKEND", "s3"),
FSRoot: getEnv("FS_ROOT", ""),
Backend: getEnv("STORAGE_BACKEND", "fs"),
FSRoot: getEnv("FS_ROOT", "./data"),
URLSecret: getEnv("URL_SECRET", ""),
URLTTL: urlTTL,
},
Database: Database{
Backend: getEnv("DB_BACKEND", "sqlite"),
SQLitePath: getEnv("SQLITE_PATH", "./galo.db"),
},
Auth: Auth{
JWTSecret: getEnv("JWT_SECRET", ""),
AccessTTL: accessTTL,
@@ -166,8 +176,18 @@ func Load() (*Config, error) {
if cfg.Auth.JWTSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required")
}
if cfg.Postgres.Password == "" {
return nil, fmt.Errorf("PG_PASSWORD is required")
switch cfg.Database.Backend {
case "postgres":
if cfg.Postgres.Password == "" {
return nil, fmt.Errorf("PG_PASSWORD is required")
}
case "sqlite":
if cfg.Database.SQLitePath == "" {
return nil, fmt.Errorf("SQLITE_PATH is required when DB_BACKEND=sqlite")
}
default:
return nil, fmt.Errorf("invalid DB_BACKEND %q (want postgres or sqlite)", cfg.Database.Backend)
}
switch cfg.Storage.Backend {
+70 -6
View File
@@ -12,15 +12,15 @@ func setRequiredEnv(t *testing.T) {
}
func TestLoadStorageBackend(t *testing.T) {
t.Run("defaults to s3", func(t *testing.T) {
t.Run("defaults to fs", func(t *testing.T) {
setRequiredEnv(t)
t.Setenv("STORAGE_BACKEND", "")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Storage.Backend != "s3" {
t.Fatalf("backend = %q, want s3", cfg.Storage.Backend)
if cfg.Storage.Backend != "fs" || cfg.Storage.FSRoot != "./data" {
t.Fatalf("storage = %+v", cfg.Storage)
}
})
@@ -37,13 +37,17 @@ func TestLoadStorageBackend(t *testing.T) {
}
})
t.Run("fs without root", func(t *testing.T) {
t.Run("fs empty root falls back to default", func(t *testing.T) {
setRequiredEnv(t)
t.Setenv("STORAGE_BACKEND", "fs")
t.Setenv("FS_ROOT", "")
t.Setenv("URL_SECRET", "")
if _, err := Load(); err == nil {
t.Fatal("expected error for fs without FS_ROOT")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Storage.FSRoot != "./data" {
t.Fatalf("FSRoot = %q, want ./data", cfg.Storage.FSRoot)
}
})
@@ -68,3 +72,63 @@ func TestLoadStorageBackend(t *testing.T) {
}
})
}
func TestLoadDatabaseBackend(t *testing.T) {
t.Run("defaults to sqlite", func(t *testing.T) {
setRequiredEnv(t)
t.Setenv("DB_BACKEND", "")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Database.Backend != "sqlite" || cfg.Database.SQLitePath != "./galo.db" {
t.Fatalf("database = %+v", cfg.Database)
}
})
t.Run("postgres requires password", func(t *testing.T) {
t.Setenv("JWT_SECRET", "s")
t.Setenv("PG_PASSWORD", "")
t.Setenv("DB_BACKEND", "postgres")
if _, err := Load(); err == nil {
t.Fatal("expected error for postgres without PG_PASSWORD")
}
})
t.Run("sqlite with path", func(t *testing.T) {
t.Setenv("JWT_SECRET", "s")
t.Setenv("PG_PASSWORD", "")
t.Setenv("DB_BACKEND", "sqlite")
t.Setenv("SQLITE_PATH", "/data/galo.db")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Database.Backend != "sqlite" || cfg.Database.SQLitePath != "/data/galo.db" {
t.Fatalf("database = %+v", cfg.Database)
}
})
t.Run("sqlite empty path falls back to default", func(t *testing.T) {
t.Setenv("JWT_SECRET", "s")
t.Setenv("PG_PASSWORD", "")
t.Setenv("DB_BACKEND", "sqlite")
t.Setenv("SQLITE_PATH", "")
cfg, err := Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Database.SQLitePath != "./galo.db" {
t.Fatalf("SQLitePath = %q, want ./galo.db", cfg.Database.SQLitePath)
}
})
t.Run("invalid backend", func(t *testing.T) {
t.Setenv("JWT_SECRET", "s")
t.Setenv("PG_PASSWORD", "p")
t.Setenv("DB_BACKEND", "mysql")
if _, err := Load(); err == nil {
t.Fatal("expected error for invalid backend")
}
})
}
+31
View File
@@ -8,8 +8,10 @@ import (
"database/sql"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/pgx/v5"
sqlitemig "github.com/golang-migrate/migrate/v4/database/sqlite"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
)
// Migrate applies all up migrations from the given fs (root contains *.sql files).
@@ -39,3 +41,32 @@ func Migrate(dsn string, migrations fs.FS) error {
}
return nil
}
// MigrateSQLite applies all up migrations from the given fs to a SQLite database
// at path (creating it if needed).
func MigrateSQLite(path string, migrations fs.FS) error {
source, err := iofs.New(migrations, ".")
if err != nil {
return fmt.Errorf("migrations source: %w", err)
}
sqlDB, err := sql.Open("sqlite", path)
if err != nil {
return fmt.Errorf("open sql db: %w", err)
}
defer sqlDB.Close()
driver, err := sqlitemig.WithInstance(sqlDB, &sqlitemig.Config{})
if err != nil {
return fmt.Errorf("migrate driver: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "sqlite", driver)
if err != nil {
return fmt.Errorf("migrate instance: %w", err)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("migrate up: %w", err)
}
return nil
}
@@ -0,0 +1,37 @@
package db
import (
"io/fs"
"path/filepath"
"testing"
assets "shared-album"
)
func TestMigrateSQLite(t *testing.T) {
path := filepath.Join(t.TempDir(), "meta.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate again: %v", err)
}
db, err := OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
var n int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name='objects'`).Scan(&n); err != nil {
t.Fatalf("query: %v", err)
}
if n != 1 {
t.Fatalf("objects table missing after migration")
}
}
+31
View File
@@ -0,0 +1,31 @@
package db
import (
"database/sql"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
// OpenSQLite opens (creating if needed) a SQLite database tuned for a single
// writer with concurrent readers: WAL journalling, a busy timeout so writers
// wait instead of failing, and enforced foreign keys.
func OpenSQLite(path string) (*sql.DB, error) {
if strings.TrimSpace(path) == "" {
return nil, fmt.Errorf("sqlite path is required")
}
dsn := fmt.Sprintf(
"file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_txlock=immediate",
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)
}
return db, nil
}
+45
View File
@@ -0,0 +1,45 @@
package db
import (
"context"
"path/filepath"
"strings"
"testing"
)
func TestOpenSQLite(t *testing.T) {
ctx := context.Background()
db, err := OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer db.Close()
if err := db.PingContext(ctx); err != nil {
t.Fatalf("ping: %v", err)
}
var mode string
if err := db.QueryRowContext(ctx, "PRAGMA journal_mode").Scan(&mode); err != nil {
t.Fatalf("journal_mode: %v", err)
}
if !strings.EqualFold(mode, "wal") {
t.Fatalf("journal_mode = %q, want wal", mode)
}
var fk int
if err := db.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&fk); err != nil {
t.Fatalf("foreign_keys: %v", err)
}
if fk != 1 {
t.Fatalf("foreign_keys = %d, want 1", fk)
}
var busy int
if err := db.QueryRowContext(ctx, "PRAGMA busy_timeout").Scan(&busy); err != nil {
t.Fatalf("busy_timeout: %v", err)
}
if busy != 5000 {
t.Fatalf("busy_timeout = %d, want 5000", busy)
}
}
+150
View File
@@ -0,0 +1,150 @@
package e2e
import (
"bytes"
"context"
"database/sql"
"image"
"image/color"
"image/png"
"io/fs"
"path/filepath"
"testing"
"time"
assets "shared-album"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/db"
"shared-album/internal/photos"
"shared-album/internal/sse"
"shared-album/internal/storage"
"shared-album/internal/store"
)
func makePNG(t *testing.T) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
for x := 0; x < 4; x++ {
for y := 0; y < 4; y++ {
img.Set(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 128, A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode png: %v", err)
}
return buf.Bytes()
}
type stack struct {
db *sql.DB
photos *photos.Service
albums *albums.Service
auth *auth.Service
}
// openStack opens a migrated SQLite database at path and builds the services.
func openStack(t *testing.T, path, blobRoot string) (*stack, func()) {
t.Helper()
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
repos := store.NewSQLite(database)
hub := sse.NewHub(1)
signer, err := storage.NewSigner("http://app.test", "secret", time.Hour)
if err != nil {
t.Fatalf("signer: %v", err)
}
blob, err := storage.NewFS(blobRoot, signer)
if err != nil {
t.Fatalf("fs: %v", err)
}
authSvc := auth.NewService(repos.Auth, auth.NewTokenManager("secret", 15*time.Minute, time.Hour))
albumsSvc := albums.NewService(repos.Albums, repos.Members, hub, blob)
photosSvc := photos.NewService(repos.Photos, albumsSvc, blob, hub)
return &stack{db: database, photos: photosSvc, albums: albumsSvc, auth: authSvc}, func() { database.Close() }
}
func TestSQLiteEndToEndWithRestart(t *testing.T) {
ctx := context.Background()
path := filepath.Join(t.TempDir(), "galo.db")
blobs := t.TempDir()
st, closeDB := openStack(t, path, blobs)
worker := photos.NewWorker(st.photos, 20*time.Millisecond, 10, time.Minute)
wctx, cancel := context.WithCancel(ctx)
go worker.Run(wctx)
user, _, err := st.auth.Register(ctx, "e2e@test.local", "password123", "E2E")
if err != nil {
t.Fatalf("register: %v", err)
}
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Trip"})
if err != nil {
t.Fatalf("create album: %v", err)
}
p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)})
if err != nil {
t.Fatalf("upload: %v", err)
}
waitFor(t, "variants of first photo", func() bool {
got, err := st.photos.Get(ctx, p.ID, user.ID)
return err == nil && got.ThumbURL != "" && got.PreviewURL != ""
})
if err := st.photos.PermanentDelete(ctx, p.ID, user.ID); err != nil {
t.Fatalf("permanent delete: %v", err)
}
waitFor(t, "garbage collection", func() bool {
var n int
return st.db.QueryRowContext(ctx, `SELECT count(*) FROM objects`).Scan(&n) == nil && n == 0
})
// Stop the worker, then queue another upload whose variant job must survive
// the restart and be processed by a fresh worker over the same file.
cancel()
time.Sleep(50 * time.Millisecond)
p2, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "b.png", Data: makePNG(t)})
if err != nil {
t.Fatalf("upload p2: %v", err)
}
got2, _ := st.photos.Get(ctx, p2.ID, user.ID)
if got2.PreviewURL != "" {
t.Fatalf("variants generated before restart")
}
closeDB()
st2, close2 := openStack(t, path, blobs)
defer close2()
worker2 := photos.NewWorker(st2.photos, 20*time.Millisecond, 10, time.Minute)
wctx2, cancel2 := context.WithCancel(ctx)
defer cancel2()
go worker2.Run(wctx2)
waitFor(t, "variants of second photo after restart", func() bool {
got, err := st2.photos.Get(ctx, p2.ID, user.ID)
return err == nil && got.ThumbURL != "" && got.PreviewURL != ""
})
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
+19
View File
@@ -0,0 +1,19 @@
package members
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the members domain.
type Repository interface {
Role(ctx context.Context, albumID, userID string) (string, error)
List(ctx context.Context, albumID string) ([]models.AlbumMember, error)
Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error
UpdateRole(ctx context.Context, albumID, userID, role string) error
Remove(ctx context.Context, albumID, userID string) error
CreateInvite(ctx context.Context, inv *models.Invite) error
GetInviteByToken(ctx context.Context, token string) (*models.Invite, error)
IncrementUses(ctx context.Context, id string) error
}
+2 -2
View File
@@ -21,13 +21,13 @@ type Notifier interface {
}
type Service struct {
store *Store
store Repository
albums AlbumReader
hub *sse.Hub
notify Notifier
}
func NewService(store *Store, albums AlbumReader, hub *sse.Hub, notify Notifier) *Service {
func NewService(store Repository, albums AlbumReader, hub *sse.Hub, notify Notifier) *Service {
return &Service{store: store, albums: albums, hub: hub, notify: notify}
}
+133
View File
@@ -0,0 +1,133 @@
package members
import (
"context"
"database/sql"
"errors"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) Role(ctx context.Context, albumID, userID string) (string, error) {
var role string
err := s.db.QueryRowContext(ctx,
`SELECT role FROM album_members WHERE album_id = $1 AND user_id = $2`,
albumID, userID).Scan(&role)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return "", err
}
return role, nil
}
func (s *SQLiteStore) List(ctx context.Context, albumID string) ([]models.AlbumMember, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT m.album_id, m.user_id, m.role, m.invited_by, m.joined_at,
u.id, u.email, u.name, COALESCE(u.avatar_url, '')
FROM album_members m
JOIN users u ON u.id = m.user_id
WHERE m.album_id = $1
ORDER BY m.joined_at`, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.AlbumMember{}
for rows.Next() {
var m models.AlbumMember
var invitedBy sql.NullString
var joinedAt string
if err := rows.Scan(&m.AlbumID, &m.UserID, &m.Role, &invitedBy, &joinedAt,
&m.User.ID, &m.User.Email, &m.User.Name, &m.User.AvatarURL); err != nil {
return nil, err
}
if invitedBy.Valid {
m.InvitedBy = &invitedBy.String
}
m.JoinedAt = sqliteutil.ParseTime(joinedAt)
out = append(out, m)
}
return out, rows.Err()
}
func (s *SQLiteStore) Add(ctx context.Context, albumID, userID, role string, invitedBy *string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO album_members (album_id, user_id, role, invited_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (album_id, user_id) DO UPDATE SET role = excluded.role`,
albumID, userID, role, invitedBy)
return err
}
func (s *SQLiteStore) UpdateRole(ctx context.Context, albumID, userID, role string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE album_members SET role = $3 WHERE album_id = $1 AND user_id = $2`,
albumID, userID, role)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) Remove(ctx context.Context, albumID, userID string) error {
res, err := s.db.ExecContext(ctx,
`DELETE FROM album_members WHERE album_id = $1 AND user_id = $2`, albumID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) CreateInvite(ctx context.Context, inv *models.Invite) error {
var createdAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO invites (id, album_id, token, role, created_by, expires_at, max_uses)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING created_at`,
inv.ID, inv.AlbumID, inv.Token, inv.Role, inv.CreatedBy, sqliteutil.FormatTime(inv.ExpiresAt), inv.MaxUses,
).Scan(&createdAt)
if err != nil {
return err
}
inv.CreatedAt = sqliteutil.ParseTime(createdAt)
return nil
}
func (s *SQLiteStore) GetInviteByToken(ctx context.Context, token string) (*models.Invite, error) {
var inv models.Invite
var expiresAt, createdAt string
err := s.db.QueryRowContext(ctx,
`SELECT id, album_id, token, role, created_by, expires_at, max_uses, uses, created_at
FROM invites WHERE token = $1`, token).
Scan(&inv.ID, &inv.AlbumID, &inv.Token, &inv.Role, &inv.CreatedBy, &expiresAt, &inv.MaxUses, &inv.Uses, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
inv.ExpiresAt = sqliteutil.ParseTime(expiresAt)
inv.CreatedAt = sqliteutil.ParseTime(createdAt)
return &inv, nil
}
func (s *SQLiteStore) IncrementUses(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, `UPDATE invites SET uses = uses + 1 WHERE id = $1`, id)
return err
}
@@ -0,0 +1,14 @@
package notifications
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the notifications domain.
type Repository interface {
Create(ctx context.Context, n *models.Notification) error
List(ctx context.Context, userID string, limit int) ([]models.Notification, error)
MarkRead(ctx context.Context, id, userID string) error
}
+2 -2
View File
@@ -10,11 +10,11 @@ import (
)
type Service struct {
store *Store
store Repository
hub *sse.Hub
}
func NewService(store *Store, hub *sse.Hub) *Service {
func NewService(store Repository, hub *sse.Hub) *Service {
return &Service{store: store, hub: hub}
}
@@ -0,0 +1,88 @@
package notifications
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) Create(ctx context.Context, n *models.Notification) error {
payload, err := json.Marshal(n.PayloadJSON)
if err != nil {
return err
}
var createdAt string
err = s.db.QueryRowContext(ctx,
`INSERT INTO notifications (id, user_id, type, payload_json)
VALUES ($1, $2, $3, $4)
RETURNING created_at`,
n.ID, n.UserID, n.Type, string(payload)).Scan(&createdAt)
if err != nil {
return err
}
n.CreatedAt = sqliteutil.ParseTime(createdAt)
return nil
}
func (s *SQLiteStore) List(ctx context.Context, userID string, limit int) ([]models.Notification, error) {
if limit <= 0 || limit > 200 {
limit = 100
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, user_id, type, payload_json, read_at, created_at
FROM notifications
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Notification{}
for rows.Next() {
var n models.Notification
var payload, createdAt string
var readAt sql.NullString
if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &payload, &readAt, &createdAt); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(payload), &n.PayloadJSON)
n.ReadAt = sqliteutil.ParseNullTime(readAt)
n.CreatedAt = sqliteutil.ParseTime(createdAt)
out = append(out, n)
}
return out, rows.Err()
}
func (s *SQLiteStore) MarkRead(ctx context.Context, id, userID string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE notifications SET read_at = $3 WHERE id = $1 AND user_id = $2 AND read_at IS NULL`,
id, userID, sqliteutil.FormatTime(time.Now()))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
var exists int
err := s.db.QueryRowContext(ctx,
`SELECT 1 FROM notifications WHERE id = $1 AND user_id = $2`, id, userID).Scan(&exists)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}
}
return nil
}
+18
View File
@@ -368,3 +368,21 @@ func (s *Store) FailVariantJob(ctx context.Context, photoID, errMsg string, maxA
}
return tx.Commit(ctx)
}
// AllStorageKeys returns every key registered in the object journal.
func (s *Store) AllStorageKeys(ctx context.Context) ([]string, error) {
rows, err := s.pool.Query(ctx, `SELECT storage_key FROM objects`)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
+52
View File
@@ -0,0 +1,52 @@
package photos
import (
"context"
"time"
"shared-album/internal/models"
)
// Repository is the persistence contract for the photos domain, including the
// object ledger, variant queue and garbage collection.
type Repository interface {
CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Object) error
DeleteUpload(ctx context.Context, photoID, objectID string) error
GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error)
List(ctx context.Context, in ListParams) ([]models.Photo, error)
SetDeleted(ctx context.Context, id string, deleted bool) error
UpdateAlbum(ctx context.Context, id, albumID string) error
Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error
HardDelete(ctx context.Context, id string) error
ListTrash(ctx context.Context, userID string) ([]models.Photo, error)
TrashCount(ctx context.Context, userID string) (int, error)
RestoreToAlbum(ctx context.Context, id, albumID string) error
AlbumExists(ctx context.Context, albumID string) (bool, error)
ListTags(ctx context.Context, userID, q string) ([]models.Tag, error)
EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error)
AttachTag(ctx context.Context, photoID, tagID string) error
DetachTag(ctx context.Context, photoID, tagID string) error
TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error)
SetCoverIfEmpty(ctx context.Context, albumID, photoID string)
UpdateFilename(ctx context.Context, id, filename string) error
BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error)
CreateObject(ctx context.Context, o *Object) error
SetObjectState(ctx context.Context, id, state string) error
ActivateObject(ctx context.Context, id string) error
MarkObjectDeleting(ctx context.Context, id string) error
DeleteObjectRow(ctx context.Context, id string) error
BindObject(ctx context.Context, photoID, objectID, role string) error
PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error)
ObjectRefCount(ctx context.Context, objectID string) (int, error)
OriginalObject(ctx context.Context, photoID string) (*Object, error)
PhotosByOriginalObject(ctx context.Context, originalObjectID string) ([]string, error)
ClaimGarbage(ctx context.Context, limit int) ([]Object, error)
ClaimStalePending(ctx context.Context, olderThan time.Duration, limit int) ([]Object, error)
ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error)
CompleteVariantJob(ctx context.Context, photoID string) error
FailVariantJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error
// AllStorageKeys returns every key registered in the object journal.
AllStorageKeys(ctx context.Context) ([]string, error)
}
+2 -2
View File
@@ -22,13 +22,13 @@ type AlbumAccess interface {
}
type Service struct {
store *Store
store Repository
albums AlbumAccess
storage storage.Backend
hub *sse.Hub
}
func NewService(store *Store, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service {
func NewService(store Repository, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service {
return &Service{store: store, albums: albums, storage: storage, hub: hub}
}
+30
View File
@@ -0,0 +1,30 @@
package photos
import (
"time"
"shared-album/internal/sqliteutil"
)
func nullIfEmpty(s string) any {
if s == "" {
return nil
}
return s
}
func nullTime(t *time.Time) any {
if t == nil {
return nil
}
return sqliteutil.FormatTime(*t)
}
func nullFloat(f *float64) any {
if f == nil {
return nil
}
return *f
}
func nowUTC() time.Time { return time.Now().UTC() }
+365
View File
@@ -0,0 +1,365 @@
package photos
import (
"context"
"database/sql"
"errors"
"time"
"shared-album/internal/sqliteutil"
)
// CreateObject inserts a new object row in the pending state.
func (s *SQLiteStore) CreateObject(ctx context.Context, o *Object) error {
if o.State == "" {
o.State = ObjectPending
}
var createdAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO objects (id, storage_key, mime_type, size, width, height, checksum, state)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING created_at`,
o.ID, o.StorageKey, o.MimeType, o.Size, o.Width, o.Height, o.Checksum, o.State).Scan(&createdAt)
if err != nil {
return err
}
o.CreatedAt = sqliteutil.ParseTime(createdAt)
return nil
}
// SetObjectState updates an object's lifecycle state.
func (s *SQLiteStore) SetObjectState(ctx context.Context, id, state string) error {
res, err := s.db.ExecContext(ctx, `UPDATE objects SET state = $2 WHERE id = $1`, id, state)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrObjectNotFound
}
return nil
}
// ActivateObject marks an object ready after its bytes have been stored.
func (s *SQLiteStore) ActivateObject(ctx context.Context, id string) error {
return s.SetObjectState(ctx, id, ObjectReady)
}
// MarkObjectDeleting marks an object as pending removal from storage.
func (s *SQLiteStore) MarkObjectDeleting(ctx context.Context, id string) error {
return s.SetObjectState(ctx, id, ObjectDeleting)
}
// DeleteObjectRow permanently removes an object row (after its blob is gone).
func (s *SQLiteStore) DeleteObjectRow(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM objects WHERE id = $1`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrObjectNotFound
}
return nil
}
// BindObject attaches a ready object to a photo under the given role. The write
// lock of the immediate transaction serializes it against garbage collection.
func (s *SQLiteStore) BindObject(ctx context.Context, photoID, objectID, role string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var state string
if err := tx.QueryRowContext(ctx, `SELECT state FROM objects WHERE id = $1`, objectID).Scan(&state); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrObjectNotFound
}
return err
}
if state != ObjectReady {
return ErrObjectNotReady
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, $3)
ON CONFLICT (photo_id, role) DO UPDATE SET object_id = excluded.object_id`,
photoID, objectID, role); err != nil {
return err
}
return tx.Commit()
}
// PhotoObjectIDs returns the object ids currently bound to a photo.
func (s *SQLiteStore) PhotoObjectIDs(ctx context.Context, photoID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = $1`, photoID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ObjectRefCount counts how many photo bindings reference an object.
func (s *SQLiteStore) ObjectRefCount(ctx context.Context, objectID string) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM photo_objects WHERE object_id = $1`, objectID).Scan(&n)
return n, err
}
// OriginalObject returns the original object bound to a photo.
func (s *SQLiteStore) OriginalObject(ctx context.Context, photoID string) (*Object, error) {
var o Object
var createdAt string
err := s.db.QueryRowContext(ctx,
`SELECT o.id, o.storage_key, o.mime_type, o.size, o.width, o.height, o.checksum, o.state, o.created_at
FROM photo_objects po JOIN objects o ON o.id = po.object_id
WHERE po.photo_id = $1 AND po.role = $2`, photoID, RoleOriginal).
Scan(&o.ID, &o.StorageKey, &o.MimeType, &o.Size, &o.Width, &o.Height, &o.Checksum, &o.State, &createdAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrObjectNotFound
}
return nil, err
}
o.CreatedAt = sqliteutil.ParseTime(createdAt)
return &o, nil
}
// PhotosByOriginalObject returns the ids of all photos bound to the given
// original object, so generated variants can be shared between copies.
func (s *SQLiteStore) PhotosByOriginalObject(ctx context.Context, originalObjectID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT photo_id FROM photo_objects WHERE object_id = $1 AND role = $2`, originalObjectID, RoleOriginal)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
// ClaimGarbage claims collectible objects: ready objects with no bindings and
// objects already marked deleting.
func (s *SQLiteStore) ClaimGarbage(ctx context.Context, limit int) ([]Object, error) {
if limit <= 0 {
limit = 10
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx,
`SELECT o.id, o.storage_key FROM objects o
WHERE o.state IN ('ready', 'deleting')
AND NOT EXISTS (SELECT 1 FROM photo_objects po WHERE po.object_id = o.id)
ORDER BY o.created_at
LIMIT $1`, limit)
if err != nil {
return nil, err
}
objs, err := scanObjectKeys(rows)
if err != nil {
return nil, err
}
if len(objs) == 0 {
return nil, tx.Commit()
}
if err := markDeleting(ctx, tx, objs); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return objs, nil
}
// ClaimStalePending marks abandoned pending objects as deleting and returns them.
func (s *SQLiteStore) ClaimStalePending(ctx context.Context, olderThan time.Duration, limit int) ([]Object, error) {
if limit <= 0 {
limit = 10
}
threshold := sqliteutil.FormatTime(time.Now().UTC().Add(-olderThan))
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx,
`SELECT id, storage_key FROM objects
WHERE state = 'pending' AND created_at < $1
ORDER BY created_at
LIMIT $2`, threshold, limit)
if err != nil {
return nil, err
}
objs, err := scanObjectKeys(rows)
if err != nil {
return nil, err
}
if len(objs) == 0 {
return nil, tx.Commit()
}
if err := markDeleting(ctx, tx, objs); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return objs, nil
}
// ClaimVariantJobs leases due variant jobs and returns their photo ids.
func (s *SQLiteStore) ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) {
if limit <= 0 {
limit = 10
}
now := sqliteutil.FormatTime(time.Now().UTC())
next := sqliteutil.FormatTime(time.Now().UTC().Add(lease))
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx,
`SELECT photo_id FROM variant_jobs
WHERE next_attempt_at <= $1
ORDER BY next_attempt_at
LIMIT $2`, now, limit)
if err != nil {
return nil, err
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return nil, err
}
ids = append(ids, id)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
if len(ids) == 0 {
return nil, tx.Commit()
}
args := make([]any, 0, len(ids)+1)
args = append(args, next)
for _, id := range ids {
args = append(args, id)
}
if _, err := tx.ExecContext(ctx,
`UPDATE variant_jobs SET next_attempt_at = $1 WHERE photo_id IN (`+sqliteutil.Placeholders(2, len(ids))+`)`,
args...); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return ids, nil
}
// CompleteVariantJob removes a finished job.
func (s *SQLiteStore) CompleteVariantJob(ctx context.Context, photoID string) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM variant_jobs WHERE photo_id = $1`, photoID)
return err
}
// FailVariantJob records a failure and schedules a retry with exponential
// backoff, abandoning the job after maxAttempts.
func (s *SQLiteStore) FailVariantJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var attempts int
err = tx.QueryRowContext(ctx, `SELECT attempts FROM variant_jobs WHERE photo_id = $1`, photoID).Scan(&attempts)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return tx.Commit()
}
return err
}
newAttempts := attempts + 1
if maxAttempts > 0 && newAttempts >= maxAttempts {
if _, err := tx.ExecContext(ctx, `DELETE FROM variant_jobs WHERE photo_id = $1`, photoID); err != nil {
return err
}
} else {
secs := 10 * (1 << attempts)
if secs > 300 {
secs = 300
}
next := sqliteutil.FormatTime(time.Now().UTC().Add(time.Duration(secs) * time.Second))
if _, err := tx.ExecContext(ctx,
`UPDATE variant_jobs SET attempts = $2, next_attempt_at = $3, last_error = $4 WHERE photo_id = $1`,
photoID, newAttempts, next, errMsg); err != nil {
return err
}
}
return tx.Commit()
}
func scanObjectKeys(rows *sql.Rows) ([]Object, error) {
var objs []Object
for rows.Next() {
var o Object
if err := rows.Scan(&o.ID, &o.StorageKey); err != nil {
rows.Close()
return nil, err
}
objs = append(objs, o)
}
rows.Close()
return objs, rows.Err()
}
func markDeleting(ctx context.Context, tx *sql.Tx, objs []Object) error {
args := make([]any, len(objs))
for i, o := range objs {
args[i] = o.ID
}
_, err := tx.ExecContext(ctx,
`UPDATE objects SET state = 'deleting' WHERE id IN (`+sqliteutil.Placeholders(1, len(objs))+`)`, args...)
return err
}
// AllStorageKeys returns every key registered in the object journal.
func (s *SQLiteStore) AllStorageKeys(ctx context.Context) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT storage_key FROM objects`)
if err != nil {
return nil, err
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
+470
View File
@@ -0,0 +1,470 @@
package photos
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"shared-album/internal/auth"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
COALESCE(o_o.storage_key, ''),
COALESCE(o_t.storage_key, ''),
COALESCE(o_p.storage_key, ''),
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
u.id, u.name, COALESCE(u.avatar_url, ''),
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
(SELECT count(*) FROM comments c WHERE c.photo_id = p.id),
EXISTS(SELECT 1 FROM likes l WHERE l.photo_id = p.id AND l.user_id = $2),
COALESCE(p.exif, '{}'), p.deleted_at,
COALESCE((SELECT json_group_array(json_object('id', t.id, 'name', t.name))
FROM (SELECT t.id, t.name FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE pt.photo_id = p.id AND t.user_id = $2
ORDER BY lower(t.name)) t), '[]')`
const sqlitePhotoJoins = `
LEFT JOIN photo_objects po_o ON po_o.photo_id = p.id AND po_o.role = 'original'
LEFT JOIN objects o_o ON o_o.id = po_o.object_id
LEFT JOIN photo_objects po_t ON po_t.photo_id = p.id AND po_t.role = 'thumb'
LEFT JOIN objects o_t ON o_t.id = po_t.object_id
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
LEFT JOIN objects o_p ON o_p.id = po_p.object_id`
func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
var p models.Photo
var exifText, tagsText, createdAt string
var takenAt, deletedAt sql.NullString
var lat, long sql.NullFloat64
var liked int
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&takenAt, &lat, &long, &p.Caption, &createdAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &tagsText); err != nil {
return nil, err
}
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
if lat.Valid {
p.Latitude = &lat.Float64
}
if long.Valid {
p.Longitude = &long.Float64
}
p.CreatedAt = sqliteutil.ParseTime(createdAt)
p.DeletedAt = sqliteutil.ParseNullTime(deletedAt)
p.LikedByMe = liked != 0
setExif(&p, exifText)
setTags(&p, tagsText)
return &p, nil
}
func (s *SQLiteStore) CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Object) error {
exifJSON, err := json.Marshal(p.EXIF)
if err != nil || len(exifJSON) == 0 {
exifJSON = []byte("{}")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`INSERT INTO objects (id, storage_key, mime_type, size, width, height, checksum, state)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
obj.ID, obj.StorageKey, obj.MimeType, obj.Size, obj.Width, obj.Height, obj.Checksum, ObjectPending); err != nil {
return err
}
var createdAt string
if err := tx.QueryRowContext(ctx,
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING created_at`,
p.ID, nullIfEmpty(p.AlbumID), p.UploaderID, p.OriginalFilename,
nullTime(p.TakenAt), nullFloat(p.Latitude), nullFloat(p.Longitude), p.Caption, string(exifJSON),
).Scan(&createdAt); err != nil {
return err
}
p.CreatedAt = sqliteutil.ParseTime(createdAt)
if _, err := tx.ExecContext(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, $3)`,
p.ID, obj.ID, RoleOriginal); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO variant_jobs (photo_id) VALUES ($1) ON CONFLICT (photo_id) DO NOTHING`, p.ID); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteStore) DeleteUpload(ctx context.Context, photoID, objectID string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `DELETE FROM photos WHERE id = $1`, photoID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM objects WHERE id = $1`, objectID); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteStore) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) {
row := s.db.QueryRowContext(ctx,
`SELECT `+sqlitePhotoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id`+sqlitePhotoJoins+`
WHERE p.id = $1`, id, viewerID)
p, err := scanSQLitePhoto(row.Scan)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return p, nil
}
func (s *SQLiteStore) List(ctx context.Context, in ListParams) ([]models.Photo, error) {
if in.Limit <= 0 || in.Limit > 100 {
in.Limit = 50
}
var since any
if in.Since != nil {
since = sqliteutil.FormatTime(*in.Since)
}
rows, err := s.db.QueryContext(ctx,
`SELECT `+sqlitePhotoColumns+`
FROM photos p JOIN users u ON u.id = p.uploader_id`+sqlitePhotoJoins+`
WHERE p.album_id = $1
AND p.deleted_at IS NULL
AND ($3 = '' OR (p.created_at, p.id) < (SELECT created_at, id FROM photos WHERE id = $3))
AND ($4 IS NULL OR p.created_at > $4)
ORDER BY p.created_at DESC, p.id DESC
LIMIT $5`,
in.AlbumID, in.Viewer, in.Cursor, since, in.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Photo{}
for rows.Next() {
p, err := scanSQLitePhoto(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *p)
}
return out, rows.Err()
}
func (s *SQLiteStore) SetDeleted(ctx context.Context, id string, deleted bool) error {
var res sql.Result
var err error
if deleted {
res, err = s.db.ExecContext(ctx,
`UPDATE photos SET deleted_at = $2 WHERE id = $1 AND deleted_at IS NULL`,
id, sqliteutil.FormatTime(nowUTC()))
} else {
res, err = s.db.ExecContext(ctx, `UPDATE photos SET deleted_at = NULL WHERE id = $1`, id)
}
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) UpdateAlbum(ctx context.Context, id, albumID string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE photos SET album_id = $2 WHERE id = $1 AND deleted_at IS NULL`, id, albumID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) Clone(ctx context.Context, src *models.Photo, albumID, uploaderID, id string) error {
exifJSON, err := json.Marshal(src.EXIF)
if err != nil || len(exifJSON) == 0 {
exifJSON = []byte("{}")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
id, nullIfEmpty(albumID), uploaderID, src.OriginalFilename,
nullTime(src.TakenAt), nullFloat(src.Latitude), nullFloat(src.Longitude), src.Caption, string(exifJSON)); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO photo_objects (photo_id, object_id, role)
SELECT $1, object_id, role FROM photo_objects WHERE photo_id = $2`,
id, src.ID); err != nil {
return err
}
return tx.Commit()
}
func (s *SQLiteStore) HardDelete(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM photos WHERE id = $1`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Photo, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT `+sqlitePhotoColumns+`, COALESCE(a.title, '')
FROM photos p
JOIN users u ON u.id = p.uploader_id
LEFT JOIN albums a ON a.id = p.album_id`+sqlitePhotoJoins+`
WHERE p.deleted_at IS NOT NULL
AND (
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
OR (p.album_id IS NULL AND p.uploader_id = $1)
)
ORDER BY p.deleted_at DESC
LIMIT 200`,
userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Photo{}
for rows.Next() {
var p models.Photo
var exifText, tagsText, createdAt, albumTitle string
var takenAt, deletedAt sql.NullString
var lat, long sql.NullFloat64
var liked int
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
&p.ThumbKey, &p.PreviewKey,
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
&takenAt, &lat, &long, &p.Caption, &createdAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &albumTitle, &tagsText); err != nil {
return nil, err
}
p.TakenAt = sqliteutil.ParseNullTime(takenAt)
if lat.Valid {
p.Latitude = &lat.Float64
}
if long.Valid {
p.Longitude = &long.Float64
}
p.CreatedAt = sqliteutil.ParseTime(createdAt)
p.DeletedAt = sqliteutil.ParseNullTime(deletedAt)
p.AlbumTitle = albumTitle
p.LikedByMe = liked != 0
setExif(&p, exifText)
setTags(&p, tagsText)
out = append(out, p)
}
return out, rows.Err()
}
func (s *SQLiteStore) TrashCount(ctx context.Context, userID string) (int, error) {
var n int
err := s.db.QueryRowContext(ctx,
`SELECT count(*) FROM photos p
LEFT JOIN albums a ON a.id = p.album_id
WHERE p.deleted_at IS NOT NULL
AND (
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
SELECT 1 FROM album_members m WHERE m.album_id = a.id AND m.user_id = $1)))
OR (p.album_id IS NULL AND p.uploader_id = $1)
)`, userID).Scan(&n)
return n, err
}
func (s *SQLiteStore) RestoreToAlbum(ctx context.Context, id, albumID string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE photos SET album_id = $2, deleted_at = NULL WHERE id = $1`, id, albumID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *SQLiteStore) AlbumExists(ctx context.Context, albumID string) (bool, error) {
var ok int
err := s.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)`, albumID).Scan(&ok)
return ok != 0, err
}
func (s *SQLiteStore) ListTags(ctx context.Context, userID, q string) ([]models.Tag, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name FROM tags
WHERE user_id = $1 AND ($2 = '' OR name LIKE '%' || $2 || '%')
ORDER BY lower(name) LIMIT 50`, userID, q)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Tag{}
for rows.Next() {
var t models.Tag
if err := rows.Scan(&t.ID, &t.Name); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *SQLiteStore) EnsureTag(ctx context.Context, userID, name string) (*models.Tag, error) {
name = strings.TrimSpace(name)
if name == "" || len(name) > 64 {
return nil, ErrNotFound
}
var t models.Tag
err := s.db.QueryRowContext(ctx,
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
Scan(&t.ID, &t.Name)
if err == nil {
return &t, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
id := auth.NewID()
err = s.db.QueryRowContext(ctx,
`INSERT INTO tags (id, user_id, name) VALUES ($1, $2, $3) RETURNING id, name`, id, userID, name).
Scan(&t.ID, &t.Name)
if err != nil {
if e2 := s.db.QueryRowContext(ctx,
`SELECT id, name FROM tags WHERE user_id = $1 AND lower(name) = lower($2)`, userID, name).
Scan(&t.ID, &t.Name); e2 == nil {
return &t, nil
}
return nil, err
}
return &t, nil
}
func (s *SQLiteStore) AttachTag(ctx context.Context, photoID, tagID string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO photo_tags (photo_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, photoID, tagID)
return err
}
func (s *SQLiteStore) DetachTag(ctx context.Context, photoID, tagID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM photo_tags WHERE photo_id = $1 AND tag_id = $2`, photoID, tagID)
return err
}
func (s *SQLiteStore) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, error) {
var ok int
err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM tags WHERE id = $1 AND user_id = $2)`, tagID, userID).Scan(&ok)
return ok != 0, err
}
func (s *SQLiteStore) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
_, _ = s.db.ExecContext(ctx,
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
albumID, photoID)
}
func (s *SQLiteStore) UpdateFilename(ctx context.Context, id, filename string) error {
res, err := s.db.ExecContext(ctx, `UPDATE photos SET original_filename = $2 WHERE id = $1`, id, filename)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// BackfillEXIF mirrors the PostgreSQL implementation, reading the original blob
// of every photo with empty exif.
func (s *SQLiteStore) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT p.id, o.storage_key
FROM photos p
JOIN photo_objects po ON po.photo_id = p.id AND po.role = 'original'
JOIN objects o ON o.id = po.object_id
WHERE p.exif IS NULL OR p.exif = '{}'
ORDER BY p.created_at`)
if err != nil {
return 0, err
}
var pending []backfillRow
for rows.Next() {
var r backfillRow
if err := rows.Scan(&r.ID, &r.StorageKey); err != nil {
rows.Close()
return 0, err
}
pending = append(pending, r)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
updated := 0
for _, r := range pending {
data, err := fetch(ctx, r.StorageKey)
if err != nil {
continue
}
meta := extractEXIF(data)
if len(meta.Attributes) == 0 && meta.TakenAt == nil && meta.Latitude == nil && meta.Longitude == nil {
continue
}
attrs, err := json.Marshal(meta.Attributes)
if err != nil || len(attrs) == 0 {
attrs = []byte("{}")
}
if _, err := s.db.ExecContext(ctx,
`UPDATE photos SET exif = $2,
taken_at = COALESCE(taken_at, $3),
latitude = COALESCE(latitude, $4),
longitude = COALESCE(longitude, $5)
WHERE id = $1`,
r.ID, string(attrs), nullTime(meta.TakenAt), nullFloat(meta.Latitude), nullFloat(meta.Longitude)); err != nil {
continue
}
updated++
}
return updated, nil
}
+74
View File
@@ -0,0 +1,74 @@
// Package reconcile computes the difference between the object storage and the
// metadata journal so orphaned blobs can be reported and, on request, removed.
package reconcile
import (
"sort"
"strings"
"time"
"shared-album/internal/storage"
)
// Result holds the two kinds of divergence between storage and the journal.
type Result struct {
// Orphans are keys present in storage but absent from the journal; they are
// candidates for deletion.
Orphans []string
// Missing are keys present in the journal but absent from storage; they are
// reported only and never modified.
Missing []string
}
// Classify compares the listed storage objects with the journaled keys.
func Classify(objects []storage.ObjectInfo, journaled []string) Result {
have := make(map[string]struct{}, len(journaled))
for _, k := range journaled {
have[k] = struct{}{}
}
seen := make(map[string]struct{}, len(objects))
var orphans []string
for _, o := range objects {
if _, dup := seen[o.Key]; dup {
continue
}
seen[o.Key] = struct{}{}
if _, ok := have[o.Key]; !ok {
orphans = append(orphans, o.Key)
}
}
var missing []string
for _, k := range journaled {
if _, ok := seen[k]; !ok {
missing = append(missing, k)
}
}
sort.Strings(orphans)
sort.Strings(missing)
return Result{Orphans: orphans, Missing: missing}
}
// Deletable selects orphaned keys that are safe to remove: within prefix and
// older than minAge (a guard against racing an in-flight upload whose row is
// not written yet).
func Deletable(objects []storage.ObjectInfo, res Result, prefix string, minAge time.Duration, now time.Time) []string {
orphans := make(map[string]struct{}, len(res.Orphans))
for _, k := range res.Orphans {
orphans[k] = struct{}{}
}
var out []string
for _, o := range objects {
if _, ok := orphans[o.Key]; !ok {
continue
}
if prefix != "" && !strings.HasPrefix(o.Key, prefix) {
continue
}
if now.Sub(o.ModTime) < minAge {
continue
}
out = append(out, o.Key)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,41 @@
package reconcile
import (
"reflect"
"testing"
"time"
"shared-album/internal/storage"
)
func TestClassify(t *testing.T) {
now := time.Now()
objects := []storage.ObjectInfo{
{Key: "objects/a/original.jpg", ModTime: now},
{Key: "objects/b/original.jpg", ModTime: now},
}
journaled := []string{"objects/b/original.jpg", "objects/c/original.jpg"}
got := Classify(objects, journaled)
if !reflect.DeepEqual(got.Orphans, []string{"objects/a/original.jpg"}) {
t.Fatalf("orphans = %v", got.Orphans)
}
if !reflect.DeepEqual(got.Missing, []string{"objects/c/original.jpg"}) {
t.Fatalf("missing = %v", got.Missing)
}
}
func TestDeletableRespectsAgeAndPrefix(t *testing.T) {
now := time.Now()
objects := []storage.ObjectInfo{
{Key: "objects/old/original.jpg", ModTime: now.Add(-2 * time.Hour)},
{Key: "objects/new/original.jpg", ModTime: now.Add(-1 * time.Minute)},
{Key: "misc/other.bin", ModTime: now.Add(-2 * time.Hour)},
}
res := Result{Orphans: []string{"objects/old/original.jpg", "objects/new/original.jpg", "misc/other.bin"}}
got := Deletable(objects, res, "objects/", time.Hour, now)
if !reflect.DeepEqual(got, []string{"objects/old/original.jpg"}) {
t.Fatalf("deletable = %v, want only the old in-prefix orphan", got)
}
}
+533
View File
@@ -0,0 +1,533 @@
// Package contract holds a backend-agnostic test suite for the metadata
// repositories. The same suite runs against PostgreSQL and SQLite so both
// implementations are held to identical observable behaviour.
package contract
import (
"context"
"errors"
"testing"
"time"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/members"
"shared-album/internal/models"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
// Repos bundles one repository per domain plus a Reset hook that clears all
// metadata so each subtest starts from an empty state.
type Repos struct {
Auth auth.Repository
Albums albums.Repository
Members members.Repository
Photos photos.Repository
Social social.Repository
Notifications notifications.Repository
ShareLinks sharelinks.Repository
Reset func(ctx context.Context) error
}
// Run executes the whole contract suite against the given repositories.
func Run(t *testing.T, r Repos) {
t.Helper()
cases := []struct {
name string
fn func(*testing.T, context.Context, Repos)
}{
{"Auth", testAuth},
{"Albums", testAlbums},
{"Members", testMembers},
{"Notifications", testNotifications},
{"ShareLinks", testShareLinks},
{"Social", testSocial},
{"Photos", testPhotos},
{"Objects", testObjects},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := r.Reset(context.Background()); err != nil {
t.Fatalf("reset: %v", err)
}
tc.fn(t, context.Background(), r)
})
}
}
func newID() string { return auth.NewID() }
func seedUser(t *testing.T, ctx context.Context, r Repos, name string) *models.User {
t.Helper()
u := &models.User{ID: newID(), Email: newID()[:10] + "@test.local", PasswordHash: "x", Name: name}
if err := r.Auth.CreateUser(ctx, u); err != nil {
t.Fatalf("create user: %v", err)
}
return u
}
func seedAlbum(t *testing.T, ctx context.Context, r Repos, ownerID string) *models.Album {
t.Helper()
a := &models.Album{ID: newID(), OwnerID: ownerID, Title: "Album"}
if err := r.Albums.Create(ctx, a); err != nil {
t.Fatalf("create album: %v", err)
}
return a
}
func seedPhoto(t *testing.T, ctx context.Context, r Repos, uploaderID, albumID string) *models.Photo {
t.Helper()
obj := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), State: photos.ObjectPending}
p := &models.Photo{ID: newID(), AlbumID: albumID, UploaderID: uploaderID, OriginalFilename: "p.jpg"}
if err := r.Photos.CreateWithOriginal(ctx, p, obj); err != nil {
t.Fatalf("create photo: %v", err)
}
if err := r.Photos.ActivateObject(ctx, obj.ID); err != nil {
t.Fatalf("activate object: %v", err)
}
return p
}
func testAuth(t *testing.T, ctx context.Context, r Repos) {
u := seedUser(t, ctx, r, "Tester")
byEmail, err := r.Auth.GetUserByEmail(ctx, u.Email)
if err != nil || byEmail.ID != u.ID {
t.Fatalf("get by email: %v id=%v", err, byEmail)
}
if _, err := r.Auth.GetUserByID(ctx, u.ID); err != nil {
t.Fatalf("get by id: %v", err)
}
if _, err := r.Auth.GetUserByEmail(ctx, "missing@test.local"); !errors.Is(err, auth.ErrNotFound) {
t.Fatalf("missing email err = %v, want ErrNotFound", err)
}
if err := r.Auth.UpdatePreferences(ctx, u.ID, []byte(`{"theme":"dark"}`)); err != nil {
t.Fatalf("update preferences: %v", err)
}
got, _ := r.Auth.GetUserByID(ctx, u.ID)
if got.Preferences["theme"] != "dark" {
t.Fatalf("preferences not applied: %+v", got.Preferences)
}
if err := r.Auth.UpdatePreferences(ctx, "missing", []byte(`{}`)); !errors.Is(err, auth.ErrNotFound) {
t.Fatalf("update missing prefs err = %v, want ErrNotFound", err)
}
hash := newID()
rt := &models.RefreshToken{ID: newID(), UserID: u.ID, TokenHash: hash, ExpiresAt: time.Now().Add(time.Hour)}
if err := r.Auth.CreateRefreshToken(ctx, rt); err != nil {
t.Fatalf("create refresh: %v", err)
}
fetched, err := r.Auth.GetRefreshToken(ctx, hash)
if err != nil || fetched.ID != rt.ID {
t.Fatalf("get refresh: %v id=%v", err, fetched)
}
if err := r.Auth.RevokeRefreshToken(ctx, rt.ID); err != nil {
t.Fatalf("revoke refresh: %v", err)
}
fetched, _ = r.Auth.GetRefreshToken(ctx, hash)
if fetched.RevokedAt == nil {
t.Fatalf("refresh not revoked")
}
if err := r.Auth.RevokeAllUserTokens(ctx, u.ID); err != nil {
t.Fatalf("revoke all: %v", err)
}
}
func testAlbums(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
if a.CreatedAt.IsZero() || a.UpdatedAt.IsZero() {
t.Fatalf("album timestamps not returned: %+v", a)
}
got, err := r.Albums.GetByID(ctx, a.ID)
if err != nil || got.Title != "Album" {
t.Fatalf("get: %v %+v", err, got)
}
if oid, err := r.Albums.OwnerID(ctx, a.ID); err != nil || oid != owner.ID {
t.Fatalf("owner id: %v %v", oid, err)
}
list, err := r.Albums.ListForUser(ctx, owner.ID)
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
a.Title = "Renamed"
if err := r.Albums.Update(ctx, a); err != nil {
t.Fatalf("update: %v", err)
}
got, _ = r.Albums.GetByID(ctx, a.ID)
if got.Title != "Renamed" {
t.Fatalf("update not applied: %q", got.Title)
}
if err := r.Albums.Delete(ctx, a.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := r.Albums.GetByID(ctx, a.ID); !errors.Is(err, albums.ErrNotFound) {
t.Fatalf("after delete err = %v, want ErrNotFound", err)
}
}
func testMembers(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
member := seedUser(t, ctx, r, "Member")
a := seedAlbum(t, ctx, r, owner.ID)
if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "" {
t.Fatalf("role before add = %q", role)
}
if err := r.Members.Add(ctx, a.ID, member.ID, "member", &owner.ID); err != nil {
t.Fatalf("add: %v", err)
}
if role, err := r.Members.Role(ctx, a.ID, member.ID); err != nil || role != "member" {
t.Fatalf("role = %q err=%v", role, err)
}
list, err := r.Members.List(ctx, a.ID)
if err != nil || len(list) != 1 || list[0].User.Name != "Member" {
t.Fatalf("list: %v %+v", err, list)
}
if err := r.Members.UpdateRole(ctx, a.ID, member.ID, "editor"); err != nil {
t.Fatalf("update role: %v", err)
}
if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "editor" {
t.Fatalf("role not updated: %q", role)
}
inv := &models.Invite{ID: newID(), AlbumID: a.ID, Token: newID(), Role: "viewer", CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour), MaxUses: 2}
if err := r.Members.CreateInvite(ctx, inv); err != nil {
t.Fatalf("create invite: %v", err)
}
if inv.CreatedAt.IsZero() {
t.Fatalf("invite created_at not returned")
}
got, err := r.Members.GetInviteByToken(ctx, inv.Token)
if err != nil || got.Role != "viewer" || got.MaxUses != 2 {
t.Fatalf("get invite: %v %+v", err, got)
}
if err := r.Members.IncrementUses(ctx, inv.ID); err != nil {
t.Fatalf("increment: %v", err)
}
got, _ = r.Members.GetInviteByToken(ctx, inv.Token)
if got.Uses != 1 {
t.Fatalf("uses = %d, want 1", got.Uses)
}
if _, err := r.Members.GetInviteByToken(ctx, "missing"); !errors.Is(err, members.ErrNotFound) {
t.Fatalf("missing invite err = %v, want ErrNotFound", err)
}
if err := r.Members.Remove(ctx, a.ID, member.ID); err != nil {
t.Fatalf("remove: %v", err)
}
if role, _ := r.Members.Role(ctx, a.ID, member.ID); role != "" {
t.Fatalf("member not removed: %q", role)
}
if err := r.Members.Remove(ctx, a.ID, member.ID); !errors.Is(err, members.ErrNotFound) {
t.Fatalf("remove missing err = %v, want ErrNotFound", err)
}
}
func testNotifications(t *testing.T, ctx context.Context, r Repos) {
u := seedUser(t, ctx, r, "Notify")
n := &models.Notification{ID: newID(), UserID: u.ID, Type: "test", PayloadJSON: map[string]any{"k": "v"}}
if err := r.Notifications.Create(ctx, n); err != nil {
t.Fatalf("create: %v", err)
}
if n.CreatedAt.IsZero() {
t.Fatalf("notification created_at not returned")
}
list, err := r.Notifications.List(ctx, u.ID, 10)
if err != nil || len(list) != 1 || list[0].PayloadJSON["k"] != "v" {
t.Fatalf("list: %v %+v", err, list)
}
if err := r.Notifications.MarkRead(ctx, n.ID, u.ID); err != nil {
t.Fatalf("mark read: %v", err)
}
list, _ = r.Notifications.List(ctx, u.ID, 10)
if list[0].ReadAt == nil {
t.Fatalf("notification not marked read")
}
if err := r.Notifications.MarkRead(ctx, newID(), u.ID); !errors.Is(err, notifications.ErrNotFound) {
t.Fatalf("mark missing err = %v, want ErrNotFound", err)
}
}
func testShareLinks(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
l := &models.ShareLink{ID: newID(), AlbumID: a.ID, Token: newID(), CreatedBy: owner.ID, ExpiresAt: time.Now().Add(time.Hour)}
if err := r.ShareLinks.Create(ctx, l); err != nil {
t.Fatalf("create: %v", err)
}
if l.CreatedAt.IsZero() {
t.Fatalf("share link created_at not returned")
}
got, err := r.ShareLinks.GetByToken(ctx, l.Token)
if err != nil || !got.Active(time.Now()) {
t.Fatalf("get token: %v %+v", err, got)
}
list, err := r.ShareLinks.ListByAlbum(ctx, a.ID)
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
if err := r.ShareLinks.Revoke(ctx, l.ID, a.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
got, _ = r.ShareLinks.GetByToken(ctx, l.Token)
if got.Active(time.Now()) {
t.Fatalf("link still active after revoke")
}
if err := r.ShareLinks.Revoke(ctx, l.ID, a.ID); !errors.Is(err, sharelinks.ErrNotFound) {
t.Fatalf("revoke again err = %v, want ErrNotFound", err)
}
if _, err := r.ShareLinks.GetByToken(ctx, "missing"); !errors.Is(err, sharelinks.ErrNotFound) {
t.Fatalf("missing token err = %v, want ErrNotFound", err)
}
}
func testSocial(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
if err := r.Social.Like(ctx, p.ID, owner.ID); err != nil {
t.Fatalf("like: %v", err)
}
if err := r.Social.Like(ctx, p.ID, owner.ID); err != nil {
t.Fatalf("like twice: %v", err)
}
if err := r.Social.Unlike(ctx, p.ID, owner.ID); err != nil {
t.Fatalf("unlike: %v", err)
}
c := &models.Comment{ID: newID(), PhotoID: p.ID, UserID: owner.ID, Text: "hi"}
if err := r.Social.CreateComment(ctx, c); err != nil {
t.Fatalf("create comment: %v", err)
}
if c.CreatedAt.IsZero() || c.UpdatedAt.IsZero() {
t.Fatalf("comment timestamps not returned: %+v", c)
}
list, err := r.Social.ListComments(ctx, p.ID)
if err != nil || len(list) != 1 || list[0].Text != "hi" {
t.Fatalf("list comments: %v %+v", err, list)
}
got, err := r.Social.GetComment(ctx, c.ID)
if err != nil || got.Text != "hi" {
t.Fatalf("get comment: %v %+v", err, got)
}
if err := r.Social.DeleteComment(ctx, c.ID); err != nil {
t.Fatalf("delete comment: %v", err)
}
if _, err := r.Social.GetComment(ctx, c.ID); !errors.Is(err, social.ErrNotFound) {
t.Fatalf("get deleted err = %v, want ErrNotFound", err)
}
if err := r.Social.DeleteComment(ctx, c.ID); !errors.Is(err, social.ErrNotFound) {
t.Fatalf("delete again err = %v, want ErrNotFound", err)
}
}
func testPhotos(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
if p.CreatedAt.IsZero() {
t.Fatalf("photo created_at not returned")
}
got, err := r.Photos.GetByID(ctx, p.ID, "")
if err != nil || got.StorageKey == "" {
t.Fatalf("get photo: %v %+v", err, got)
}
list, err := r.Photos.List(ctx, photos.ListParams{AlbumID: a.ID, Limit: 10})
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
tag, err := r.Photos.EnsureTag(ctx, owner.ID, "Vacation")
if err != nil || tag.Name != "Vacation" {
t.Fatalf("ensure tag: %v %+v", err, tag)
}
tag2, err := r.Photos.EnsureTag(ctx, owner.ID, "vacation")
if err != nil || tag2.ID != tag.ID {
t.Fatalf("ensure tag case-insensitive: %v %+v", err, tag2)
}
if err := r.Photos.AttachTag(ctx, p.ID, tag.ID); err != nil {
t.Fatalf("attach tag: %v", err)
}
got, _ = r.Photos.GetByID(ctx, p.ID, owner.ID)
if len(got.Tags) != 1 || got.Tags[0].Name != "Vacation" {
t.Fatalf("photo tags = %+v", got.Tags)
}
if owned, _ := r.Photos.TagOwnedBy(ctx, tag.ID, owner.ID); !owned {
t.Fatalf("tag not owned")
}
tags, err := r.Photos.ListTags(ctx, owner.ID, "vac")
if err != nil || len(tags) != 1 {
t.Fatalf("list tags: %v len=%d", err, len(tags))
}
if err := r.Photos.DetachTag(ctx, p.ID, tag.ID); err != nil {
t.Fatalf("detach tag: %v", err)
}
r.Photos.SetCoverIfEmpty(ctx, a.ID, p.ID)
al, _ := r.Albums.GetByID(ctx, a.ID)
if al.CoverPhotoID == nil || *al.CoverPhotoID != p.ID {
t.Fatalf("cover not set: %+v", al.CoverPhotoID)
}
if err := r.Photos.SetDeleted(ctx, p.ID, true); err != nil {
t.Fatalf("soft delete: %v", err)
}
if n, _ := r.Photos.TrashCount(ctx, owner.ID); n != 1 {
t.Fatalf("trash count = %d, want 1", n)
}
trash, err := r.Photos.ListTrash(ctx, owner.ID)
if err != nil || len(trash) != 1 {
t.Fatalf("trash: %v len=%d", err, len(trash))
}
if err := r.Photos.RestoreToAlbum(ctx, p.ID, a.ID); err != nil {
t.Fatalf("restore: %v", err)
}
if n, _ := r.Photos.TrashCount(ctx, owner.ID); n != 0 {
t.Fatalf("trash count after restore = %d", n)
}
if err := r.Photos.UpdateFilename(ctx, p.ID, "renamed.jpg"); err != nil {
t.Fatalf("update filename: %v", err)
}
got, _ = r.Photos.GetByID(ctx, p.ID, "")
if got.OriginalFilename != "renamed.jpg" {
t.Fatalf("filename = %q", got.OriginalFilename)
}
if ok, _ := r.Photos.AlbumExists(ctx, a.ID); !ok {
t.Fatalf("album should exist")
}
if err := r.Photos.HardDelete(ctx, p.ID); err != nil {
t.Fatalf("hard delete: %v", err)
}
if _, err := r.Photos.GetByID(ctx, p.ID, ""); !errors.Is(err, photos.ErrNotFound) {
t.Fatalf("get deleted err = %v, want ErrNotFound", err)
}
}
func testObjects(t *testing.T, ctx context.Context, r Repos) {
owner := seedUser(t, ctx, r, "Owner")
a := seedAlbum(t, ctx, r, owner.ID)
p := seedPhoto(t, ctx, r, owner.ID, a.ID)
orig, err := r.Photos.OriginalObject(ctx, p.ID)
if err != nil || orig.State != photos.ObjectReady {
t.Fatalf("original object: %v %+v", err, orig)
}
keys, err := r.Photos.AllStorageKeys(ctx)
if err != nil || !contains(keys, orig.StorageKey) {
t.Fatalf("AllStorageKeys = %v err=%v, want %s", keys, err, orig.StorageKey)
}
if refs, _ := r.Photos.ObjectRefCount(ctx, orig.ID); refs != 1 {
t.Fatalf("refs = %d, want 1", refs)
}
sibs, _ := r.Photos.PhotosByOriginalObject(ctx, orig.ID)
if len(sibs) != 1 || sibs[0] != p.ID {
t.Fatalf("siblings = %+v", sibs)
}
thumb := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "image/webp", State: photos.ObjectPending}
if err := r.Photos.CreateObject(ctx, thumb); err != nil {
t.Fatalf("create object: %v", err)
}
if err := r.Photos.BindObject(ctx, p.ID, thumb.ID, photos.RoleThumb); !errors.Is(err, photos.ErrObjectNotReady) {
t.Fatalf("bind pending err = %v, want ErrObjectNotReady", err)
}
if err := r.Photos.ActivateObject(ctx, thumb.ID); err != nil {
t.Fatalf("activate: %v", err)
}
if err := r.Photos.BindObject(ctx, p.ID, thumb.ID, photos.RoleThumb); err != nil {
t.Fatalf("bind thumb: %v", err)
}
if refs, _ := r.Photos.ObjectRefCount(ctx, thumb.ID); refs != 1 {
t.Fatalf("thumb refs = %d, want 1", refs)
}
// After the photo is gone, its objects have no bindings and are collectible.
if err := r.Photos.HardDelete(ctx, p.ID); err != nil {
t.Fatalf("hard delete: %v", err)
}
claimed, err := r.Photos.ClaimGarbage(ctx, 10)
if err != nil {
t.Fatalf("claim garbage: %v", err)
}
got := map[string]bool{}
for _, o := range claimed {
got[o.ID] = true
}
if !got[orig.ID] || !got[thumb.ID] {
t.Fatalf("claimed = %+v, want original and thumb", got)
}
for _, o := range claimed {
if err := r.Photos.DeleteObjectRow(ctx, o.ID); err != nil {
t.Fatalf("delete object row: %v", err)
}
}
if again, _ := r.Photos.ClaimGarbage(ctx, 10); len(again) != 0 {
t.Fatalf("garbage reclaimed: %+v", again)
}
// Stale pending objects are reaped. A negative age puts the threshold in
// the future so the mechanism is exercised without depending on clock skew
// between the test process and the database.
stale := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), State: photos.ObjectPending}
if err := r.Photos.CreateObject(ctx, stale); err != nil {
t.Fatalf("create stale: %v", err)
}
staleClaimed, err := r.Photos.ClaimStalePending(ctx, -time.Hour, 10)
if err != nil || len(staleClaimed) == 0 {
t.Fatalf("claim stale: %v %+v", err, staleClaimed)
}
// Variant queue: a fresh photo has one job.
p2 := seedPhoto(t, ctx, r, owner.ID, a.ID)
jobs, err := r.Photos.ClaimVariantJobs(ctx, 10, time.Minute)
if err != nil {
t.Fatalf("claim jobs: %v", err)
}
if !contains(jobs, p2.ID) {
t.Fatalf("jobs = %+v, want %s", jobs, p2.ID)
}
if err := r.Photos.CompleteVariantJob(ctx, p2.ID); err != nil {
t.Fatalf("complete job: %v", err)
}
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p2.ID) {
t.Fatalf("completed job still claimed: %+v", jobs)
}
// Failure schedules a retry and eventually abandons the job.
p3 := seedPhoto(t, ctx, r, owner.ID, a.ID)
if err := r.Photos.FailVariantJob(ctx, p3.ID, "boom", 3); err != nil {
t.Fatalf("fail job: %v", err)
}
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p3.ID) {
t.Fatalf("failed job retried immediately: %+v", jobs)
}
for i := 0; i < 3; i++ {
if err := r.Photos.FailVariantJob(ctx, p3.ID, "boom", 3); err != nil {
t.Fatalf("fail job again: %v", err)
}
}
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p3.ID) {
t.Fatalf("abandoned job still present: %+v", jobs)
}
}
func contains(list []string, v string) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
@@ -0,0 +1,78 @@
package contract
import (
"context"
"fmt"
"io/fs"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
assets "shared-album"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/db"
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
// TestPostgresContract runs the shared suite against PostgreSQL inside its own
// schema, so it never clashes with other packages that use the same database.
func TestPostgresContract(t *testing.T) {
base := os.Getenv("TEST_DATABASE_URL")
if base == "" {
t.Skip("TEST_DATABASE_URL not set")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, base)
if err != nil {
t.Skipf("cannot connect: %v", err)
}
if err := admin.Ping(ctx); err != nil {
t.Skipf("cannot ping: %v", err)
}
t.Cleanup(admin.Close)
schema := fmt.Sprintf("ct_%d", time.Now().UnixNano())
if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
t.Fatalf("create schema: %v", err)
}
t.Cleanup(func() {
_, _ = admin.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE")
})
dsn := base + "&options=-c%20search_path%3D" + schema
migFS, err := fs.Sub(assets.Migrations, "migrations")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := db.Migrate(dsn, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
Run(t, Repos{
Auth: auth.NewStore(pool),
Albums: albums.NewStore(pool),
Members: members.NewStore(pool),
Photos: photos.NewStore(pool),
Social: social.NewStore(pool),
Notifications: notifications.NewStore(pool),
ShareLinks: sharelinks.NewStore(pool),
Reset: func(ctx context.Context) error {
_, err := pool.Exec(ctx, `TRUNCATE users, objects CASCADE`)
return err
},
})
}
@@ -0,0 +1,57 @@
package contract
import (
"context"
"io/fs"
"path/filepath"
"testing"
assets "shared-album"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/db"
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
func TestSQLiteContract(t *testing.T) {
path := filepath.Join(t.TempDir(), "meta.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { database.Close() })
Run(t, Repos{
Auth: auth.NewSQLiteStore(database),
Albums: albums.NewSQLiteStore(database),
Members: members.NewSQLiteStore(database),
Photos: photos.NewSQLiteStore(database),
Social: social.NewSQLiteStore(database),
Notifications: notifications.NewSQLiteStore(database),
ShareLinks: sharelinks.NewSQLiteStore(database),
Reset: func(ctx context.Context) error {
tables := []string{
"variant_jobs", "photo_objects", "objects", "photo_tags", "tags",
"refresh_tokens", "share_links", "notifications", "likes", "comments",
"invites", "album_members", "photos", "albums", "users",
}
for _, tbl := range tables {
if _, err := database.ExecContext(ctx, "DELETE FROM "+tbl); err != nil {
return err
}
}
return nil
},
})
}
+15
View File
@@ -0,0 +1,15 @@
package sharelinks
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the share-links domain.
type Repository interface {
Create(ctx context.Context, l *models.ShareLink) error
ListByAlbum(ctx context.Context, albumID string) ([]models.ShareLink, error)
GetByToken(ctx context.Context, token string) (*models.ShareLink, error)
Revoke(ctx context.Context, id, albumID string) error
}
+2 -2
View File
@@ -16,11 +16,11 @@ type AlbumAccess interface {
}
type Service struct {
store *Store
store Repository
albums AlbumAccess
}
func NewService(store *Store, albums AlbumAccess) *Service {
func NewService(store Repository, albums AlbumAccess) *Service {
return &Service{store: store, albums: albums}
}
@@ -0,0 +1,90 @@
package sharelinks
import (
"context"
"database/sql"
"errors"
"time"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) Create(ctx context.Context, l *models.ShareLink) error {
var createdAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO share_links (id, album_id, token, created_by, expires_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING created_at`,
l.ID, l.AlbumID, l.Token, l.CreatedBy, sqliteutil.FormatTime(l.ExpiresAt),
).Scan(&createdAt)
if err != nil {
return err
}
l.CreatedAt = sqliteutil.ParseTime(createdAt)
return nil
}
func (s *SQLiteStore) ListByAlbum(ctx context.Context, albumID string) ([]models.ShareLink, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, album_id, token, created_by, created_at, expires_at, revoked_at
FROM share_links WHERE album_id = $1 ORDER BY created_at DESC`, albumID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.ShareLink{}
for rows.Next() {
var l models.ShareLink
var createdAt, expiresAt string
var revokedAt sql.NullString
if err := rows.Scan(&l.ID, &l.AlbumID, &l.Token, &l.CreatedBy, &createdAt, &expiresAt, &revokedAt); err != nil {
return nil, err
}
l.CreatedAt = sqliteutil.ParseTime(createdAt)
l.ExpiresAt = sqliteutil.ParseTime(expiresAt)
l.RevokedAt = sqliteutil.ParseNullTime(revokedAt)
out = append(out, l)
}
return out, rows.Err()
}
func (s *SQLiteStore) GetByToken(ctx context.Context, token string) (*models.ShareLink, error) {
var l models.ShareLink
var createdAt, expiresAt string
var revokedAt sql.NullString
err := s.db.QueryRowContext(ctx,
`SELECT id, album_id, token, created_by, created_at, expires_at, revoked_at
FROM share_links WHERE token = $1`, token).
Scan(&l.ID, &l.AlbumID, &l.Token, &l.CreatedBy, &createdAt, &expiresAt, &revokedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
l.CreatedAt = sqliteutil.ParseTime(createdAt)
l.ExpiresAt = sqliteutil.ParseTime(expiresAt)
l.RevokedAt = sqliteutil.ParseNullTime(revokedAt)
return &l, nil
}
func (s *SQLiteStore) Revoke(ctx context.Context, id, albumID string) error {
res, err := s.db.ExecContext(ctx,
`UPDATE share_links SET revoked_at = $3 WHERE id = $1 AND album_id = $2 AND revoked_at IS NULL`,
id, albumID, sqliteutil.FormatTime(time.Now()))
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+17
View File
@@ -0,0 +1,17 @@
package social
import (
"context"
"shared-album/internal/models"
)
// Repository is the persistence contract for the social domain.
type Repository interface {
Like(ctx context.Context, photoID, userID string) error
Unlike(ctx context.Context, photoID, userID string) error
CreateComment(ctx context.Context, c *models.Comment) error
ListComments(ctx context.Context, photoID string) ([]models.Comment, error)
GetComment(ctx context.Context, id string) (*models.Comment, error)
DeleteComment(ctx context.Context, id string) error
}
+2 -2
View File
@@ -25,14 +25,14 @@ type Notifier interface {
}
type Service struct {
store *Store
store Repository
albums AlbumAccess
photos PhotoLookup
hub *sse.Hub
notify Notifier
}
func NewService(store *Store, albums AlbumAccess, photos PhotoLookup, hub *sse.Hub, notify Notifier) *Service {
func NewService(store Repository, albums AlbumAccess, photos PhotoLookup, hub *sse.Hub, notify Notifier) *Service {
return &Service{store: store, albums: albums, photos: photos, hub: hub, notify: notify}
}
+101
View File
@@ -0,0 +1,101 @@
package social
import (
"context"
"database/sql"
"errors"
"shared-album/internal/models"
"shared-album/internal/sqliteutil"
)
// SQLiteStore is the SQLite implementation of Repository.
type SQLiteStore struct {
db *sql.DB
}
func NewSQLiteStore(db *sql.DB) *SQLiteStore { return &SQLiteStore{db: db} }
func (s *SQLiteStore) Like(ctx context.Context, photoID, userID string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO likes (photo_id, user_id) VALUES ($1, $2)
ON CONFLICT (photo_id, user_id) DO NOTHING`, photoID, userID)
return err
}
func (s *SQLiteStore) Unlike(ctx context.Context, photoID, userID string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM likes WHERE photo_id = $1 AND user_id = $2`, photoID, userID)
return err
}
func (s *SQLiteStore) CreateComment(ctx context.Context, c *models.Comment) error {
var createdAt, updatedAt string
err := s.db.QueryRowContext(ctx,
`INSERT INTO comments (id, photo_id, user_id, text)
VALUES ($1, $2, $3, $4)
RETURNING created_at, updated_at`,
c.ID, c.PhotoID, c.UserID, c.Text,
).Scan(&createdAt, &updatedAt)
if err != nil {
return err
}
c.CreatedAt = sqliteutil.ParseTime(createdAt)
c.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return nil
}
func (s *SQLiteStore) ListComments(ctx context.Context, photoID string) ([]models.Comment, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT c.id, c.photo_id, c.user_id, c.text, c.created_at, c.updated_at,
u.id, u.name, COALESCE(u.avatar_url, '')
FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.photo_id = $1
ORDER BY c.created_at`, photoID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Comment{}
for rows.Next() {
var c models.Comment
var createdAt, updatedAt string
if err := rows.Scan(&c.ID, &c.PhotoID, &c.UserID, &c.Text, &createdAt, &updatedAt,
&c.User.ID, &c.User.Name, &c.User.AvatarURL); err != nil {
return nil, err
}
c.CreatedAt = sqliteutil.ParseTime(createdAt)
c.UpdatedAt = sqliteutil.ParseTime(updatedAt)
out = append(out, c)
}
return out, rows.Err()
}
func (s *SQLiteStore) GetComment(ctx context.Context, id string) (*models.Comment, error) {
var c models.Comment
var createdAt, updatedAt string
err := s.db.QueryRowContext(ctx,
`SELECT id, photo_id, user_id, text, created_at, updated_at FROM comments WHERE id = $1`, id).
Scan(&c.ID, &c.PhotoID, &c.UserID, &c.Text, &createdAt, &updatedAt)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
c.CreatedAt = sqliteutil.ParseTime(createdAt)
c.UpdatedAt = sqliteutil.ParseTime(updatedAt)
return &c, nil
}
func (s *SQLiteStore) DeleteComment(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM comments WHERE id = $1`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
// Package sqliteutil holds small helpers shared by the SQLite repositories:
// timestamp conversion and dynamic IN-lists.
package sqliteutil
import (
"database/sql"
"strconv"
"strings"
"time"
)
// TimeLayout matches the SQLite column defaults produced by strftime with %f.
const TimeLayout = "2006-01-02T15:04:05.000Z"
// FormatTime renders a time in the canonical TEXT format used by the schema.
func FormatTime(t time.Time) string { return t.UTC().Format(TimeLayout) }
// ParseTime parses a canonical timestamp, tolerating the RFC3339 variants the
// driver may return. An empty or unparseable value yields the zero time.
func ParseTime(s string) time.Time {
if s == "" {
return time.Time{}
}
if t, err := time.Parse(TimeLayout, s); err == nil {
return t
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t
}
return time.Time{}
}
// ParseNullTime converts a nullable TEXT timestamp into *time.Time.
func ParseNullTime(ns sql.NullString) *time.Time {
if !ns.Valid || ns.String == "" {
return nil
}
t := ParseTime(ns.String)
return &t
}
// Placeholders returns an IN-list placeholder group of n items starting at
// index start, e.g. Placeholders(3, 2) -> "$3,$4".
func Placeholders(start, n int) string {
parts := make([]string, n)
for i := 0; i < n; i++ {
parts[i] = "$" + strconv.Itoa(start+i)
}
return strings.Join(parts, ",")
}
+32
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"net/url"
@@ -144,6 +145,37 @@ func (f *FS) URL(_ context.Context, key string) (string, error) {
return f.signer.URL(key), nil
}
// List returns every blob under prefix as a key relative to the storage root.
func (f *FS) List(_ context.Context, prefix string) ([]ObjectInfo, error) {
var out []ObjectInfo
err := filepath.WalkDir(f.root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(f.root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if prefix != "" && !strings.HasPrefix(rel, prefix) {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
out = append(out, ObjectInfo{Key: rel, Size: info.Size(), ModTime: info.ModTime()})
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
// ServeObject validates the signed URL and serves the blob. It supports range
// requests and sets the content type from the key extension. It writes the
// response itself and returns nil on success.
+30
View File
@@ -172,3 +172,33 @@ func TestContentTypeForKey(t *testing.T) {
}
}
}
func TestFSList(t *testing.T) {
ctx := context.Background()
fs := newTestFS(t)
for _, key := range []string{"objects/a/original.jpg", "objects/b/original.jpg", "misc/readme.txt"} {
if err := fs.Put(ctx, key, strings.NewReader("x"), 1, "text/plain"); err != nil {
t.Fatalf("put %s: %v", key, err)
}
}
all, err := fs.List(ctx, "")
if err != nil || len(all) != 3 {
t.Fatalf("list all: %v len=%d", err, len(all))
}
objs, err := fs.List(ctx, "objects/")
if err != nil {
t.Fatalf("list objects: %v", err)
}
if len(objs) != 2 {
t.Fatalf("list objects len=%d, want 2", len(objs))
}
for _, o := range objs {
if !strings.HasPrefix(o.Key, "objects/") {
t.Fatalf("unexpected key %q", o.Key)
}
if o.Size != 1 || o.ModTime.IsZero() {
t.Fatalf("unexpected info %+v", o)
}
}
}
+27
View File
@@ -117,6 +117,33 @@ func TestS3ServeObject(t *testing.T) {
})
}
func TestS3List(t *testing.T) {
ctx := context.Background()
cl, _ := newTestS3(t)
prefix := "objects/" + strconv.FormatInt(time.Now().UnixNano(), 10) + "/"
keys := []string{prefix + "original.jpg", prefix + "thumb.webp"}
for _, k := range keys {
if err := cl.Put(ctx, k, strings.NewReader("x"), 1, "image/jpeg"); err != nil {
t.Fatalf("put %s: %v", k, err)
}
}
t.Cleanup(func() { _ = cl.Delete(context.Background(), keys...) })
got, err := cl.List(ctx, prefix)
if err != nil {
t.Fatalf("list: %v", err)
}
found := map[string]bool{}
for _, o := range got {
found[o.Key] = true
}
for _, k := range keys {
if !found[k] {
t.Fatalf("key %s not listed: %+v", k, got)
}
}
}
func TestS3ServeObjectHead(t *testing.T) {
ctx := context.Background()
cl, signer := newTestS3(t)
+40
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
@@ -29,6 +30,13 @@ var ErrObjectNotFound = errors.New("object not found")
// ErrRangeNotSatisfiable is returned when a requested byte range is invalid.
var ErrRangeNotSatisfiable = errors.New("requested range not satisfiable")
// ObjectInfo describes one stored blob for inventory and reconciliation.
type ObjectInfo struct {
Key string
Size int64
ModTime time.Time
}
// Backend is the object storage abstraction. Keys are opaque and independent of
// the backend: every implementation stores and serves the same logical blob.
// Blobs are always delivered through the app.
@@ -41,6 +49,8 @@ type Backend interface {
// ServeObject validates the signed URL and writes the blob, including
// Content-Type and range handling, to the response.
ServeObject(w http.ResponseWriter, r *http.Request, key string) error
// List returns the blobs under prefix for inventory and reconciliation.
List(ctx context.Context, prefix string) ([]ObjectInfo, error)
Ping(ctx context.Context) error
EnsureBucket(ctx context.Context) error
// Bucket returns a human-readable identifier for the storage location.
@@ -183,6 +193,36 @@ func (c *S3) URL(_ context.Context, key string) (string, error) {
return c.signer.URL(key), nil
}
// List returns every object under prefix using paginated ListObjectsV2.
func (c *S3) List(ctx context.Context, prefix string) ([]ObjectInfo, error) {
var out []ObjectInfo
var token *string
for {
in := &s3.ListObjectsV2Input{Bucket: aws.String(c.bucket)}
if prefix != "" {
in.Prefix = aws.String(prefix)
}
if token != nil {
in.ContinuationToken = token
}
res, err := c.s3.ListObjectsV2(ctx, in)
if err != nil {
return nil, fmt.Errorf("list objects: %w", err)
}
for _, o := range res.Contents {
out = append(out, ObjectInfo{
Key: aws.ToString(o.Key),
Size: aws.ToInt64(o.Size),
ModTime: aws.ToTime(o.LastModified),
})
}
if res.IsTruncated == nil || !*res.IsTruncated {
return out, nil
}
token = res.NextContinuationToken
}
}
// ServeObject validates the signed URL and streams the blob from S3. It passes
// the client's Range header through to S3, so byte ranges are handled natively,
// and answers HEAD via HeadObject. It writes the response itself.
+55
View File
@@ -0,0 +1,55 @@
// Package store builds the per-domain metadata repositories for a chosen
// backend. Domains depend on their Repository interfaces; this factory is the
// single place that knows the concrete implementations.
package store
import (
"database/sql"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/albums"
"shared-album/internal/auth"
"shared-album/internal/members"
"shared-album/internal/notifications"
"shared-album/internal/photos"
"shared-album/internal/sharelinks"
"shared-album/internal/social"
)
// Repos bundles one repository per domain.
type Repos struct {
Auth auth.Repository
Albums albums.Repository
Members members.Repository
Photos photos.Repository
Social social.Repository
Notifications notifications.Repository
ShareLinks sharelinks.Repository
}
// NewPostgres builds the repositories backed by PostgreSQL.
func NewPostgres(pool *pgxpool.Pool) Repos {
return Repos{
Auth: auth.NewStore(pool),
Albums: albums.NewStore(pool),
Members: members.NewStore(pool),
Photos: photos.NewStore(pool),
Social: social.NewStore(pool),
Notifications: notifications.NewStore(pool),
ShareLinks: sharelinks.NewStore(pool),
}
}
// NewSQLite builds the repositories backed by SQLite.
func NewSQLite(db *sql.DB) Repos {
return Repos{
Auth: auth.NewSQLiteStore(db),
Albums: albums.NewSQLiteStore(db),
Members: members.NewSQLiteStore(db),
Photos: photos.NewSQLiteStore(db),
Social: social.NewSQLiteStore(db),
Notifications: notifications.NewSQLiteStore(db),
ShareLinks: sharelinks.NewSQLiteStore(db),
}
}
+55
View File
@@ -0,0 +1,55 @@
package store
import (
"context"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
assets "shared-album"
"shared-album/internal/db"
)
func assertComplete(t *testing.T, r Repos) {
t.Helper()
if r.Auth == nil || r.Albums == nil || r.Members == nil || r.Photos == nil ||
r.Social == nil || r.Notifications == nil || r.ShareLinks == nil {
t.Fatalf("incomplete repository bundle: %+v", r)
}
}
func TestNewSQLite(t *testing.T) {
path := filepath.Join(t.TempDir(), "meta.db")
migFS, err := fs.Sub(assets.Migrations, "migrations/sqlite")
if err != nil {
t.Fatalf("migrations fs: %v", err)
}
if err := db.MigrateSQLite(path, migFS); err != nil {
t.Fatalf("migrate: %v", err)
}
database, err := db.OpenSQLite(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer database.Close()
assertComplete(t, NewSQLite(database))
}
func TestNewPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Skipf("cannot connect: %v", err)
}
if err := pool.Ping(context.Background()); err != nil {
t.Skipf("cannot ping: %v", err)
}
t.Cleanup(pool.Close)
assertComplete(t, NewPostgres(pool))
}