feat(photos): separate object storage from metadata layer

Move physical file facts out of the photos table into an objects ledger:

- objects / photo_objects / variant_jobs tables (migration 000008)
- drop storage_key/thumb_key/preview_key/size/width/height/mime_type from
  photos (migration 000009); they are derived via joins to the original object
- album-free storage keys: objects/{id}/{role}.{ext}
- explicit GC via pending/ready/deleting states; objects deleted only when no
  photo references them
- background worker drives variant generation (durable variant_jobs) and GC
- copy/move are metadata-only; storage objects are shared between photos
- remove unused storage.DeletePrefix

Non-destructive to the external JSON API: size/dimensions/mime and URLs are
still returned, just sourced from the object layer.

OpenSpec change: media-object-layer
This commit is contained in:
iu
2026-10-10 23:31:41 +03:00
parent 9effc0fc82
commit 96268ee16c
30 changed files with 1835 additions and 136 deletions
+149
View File
@@ -0,0 +1,149 @@
package photos
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/auth"
"shared-album/internal/config"
"shared-album/internal/models"
"shared-album/internal/roles"
"shared-album/internal/sse"
"shared-album/internal/storage"
)
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
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)
return pool
}
func testEnvOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
type stubAlbums struct{}
func (stubAlbums) Access(ctx context.Context, albumID, userID string) (*models.Album, string, error) {
return &models.Album{ID: albumID}, roles.Owner, nil
}
func createUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool) string {
t.Helper()
id := auth.NewID()
if _, err := pool.Exec(ctx,
`INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, 'x', 't')`,
id, id+"@test.local"); err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, id) })
return id
}
func createAlbum(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ownerID string) string {
t.Helper()
id := auth.NewID()
if _, err := pool.Exec(ctx,
`INSERT INTO albums (id, owner_id, title) VALUES ($1, $2, 't')`, id, ownerID); err != nil {
t.Fatalf("create album: %v", err)
}
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM albums WHERE id = $1`, id) })
return id
}
func createPhoto(t *testing.T, ctx context.Context, pool *pgxpool.Pool, uploaderID string) string {
t.Helper()
id := auth.NewID()
if _, err := pool.Exec(ctx,
`INSERT INTO photos (id, album_id, uploader_id) VALUES ($1, NULL, $2)`, id, uploaderID); err != nil {
t.Fatalf("create photo: %v", err)
}
return id
}
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()
}
// newTestService builds a service backed by the real dev storage. It skips when
// S3 env is absent so unit runs stay hermetic.
func newTestService(t *testing.T, pool *pgxpool.Pool) (*Service, *storage.Client) {
t.Helper()
endpoint := os.Getenv("S3_ENDPOINT")
if endpoint == "" {
t.Skip("S3_ENDPOINT not set")
}
cl, err := storage.New(context.Background(), config.S3{
Endpoint: endpoint,
Region: testEnvOr("S3_REGION", "garage"),
AccessKey: os.Getenv("S3_ACCESS_KEY"),
SecretKey: os.Getenv("S3_SECRET_KEY"),
Bucket: testEnvOr("S3_BUCKET", "galo-data"),
UsePathStyle: testEnvOr("S3_USE_PATH_STYLE", "true") != "false",
PresignTTL: time.Hour,
})
if err != nil {
t.Skipf("storage init: %v", err)
}
return NewService(NewStore(pool), stubAlbums{}, cl, sse.NewHub(1)), cl
}
// cleanupPhoto removes a photo, its objects (rows and blobs) and bindings.
func cleanupPhoto(t *testing.T, ctx context.Context, pool *pgxpool.Pool, cl *storage.Client, photoID string) {
t.Helper()
rows, err := pool.Query(ctx,
`SELECT o.id, o.storage_key FROM photo_objects po JOIN objects o ON o.id = po.object_id
WHERE po.photo_id = $1`, photoID)
if err != nil {
return
}
var ids, keys []string
for rows.Next() {
var id, key string
if rows.Scan(&id, &key) == nil {
ids = append(ids, id)
keys = append(keys, key)
}
}
rows.Close()
_, _ = pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photoID)
for _, id := range ids {
_, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, id)
}
if cl != nil && len(keys) > 0 {
_ = cl.Delete(ctx, keys...)
}
}