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:
@@ -29,6 +29,7 @@ func testPool(t *testing.T) *pgxpool.Pool {
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
t.Skipf("cannot ping: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return pool
|
||||
}
|
||||
|
||||
@@ -48,7 +49,6 @@ func newUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, name string)
|
||||
|
||||
func TestUserRepository(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
store := auth.NewStore(pool)
|
||||
|
||||
@@ -82,7 +82,6 @@ func TestUserRepository(t *testing.T) {
|
||||
|
||||
func TestAlbumRepository(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
store := albums.NewStore(pool)
|
||||
|
||||
@@ -126,7 +125,6 @@ func TestAlbumRepository(t *testing.T) {
|
||||
|
||||
func TestAlbumCoverRepository(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
store := albums.NewStore(pool)
|
||||
|
||||
@@ -138,18 +136,33 @@ func TestAlbumCoverRepository(t *testing.T) {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
|
||||
insertPhoto := func(id, thumbKey string) {
|
||||
insertPhoto := func(id string) string {
|
||||
t.Helper()
|
||||
origObj := auth.NewID()
|
||||
thumbObj := auth.NewID()
|
||||
thumbKey := "objects/" + thumbObj + "/thumb.webp"
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
id, a.ID, owner.ID, "albums/"+a.ID+"/original/"+id+".jpg", thumbKey); err != nil {
|
||||
`INSERT INTO objects (id, storage_key, mime_type, state) VALUES
|
||||
($1, $2, 'image/jpeg', 'ready'),
|
||||
($3, $4, 'image/webp', 'ready')`,
|
||||
origObj, "objects/"+origObj+"/original.jpg", thumbObj, thumbKey); err != nil {
|
||||
t.Fatalf("insert objects: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO photos (id, album_id, uploader_id) VALUES ($1, $2, $3)`,
|
||||
id, a.ID, owner.ID); err != nil {
|
||||
t.Fatalf("insert photo: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO photo_objects (photo_id, object_id, role) VALUES ($1, $2, 'original'), ($1, $3, 'thumb')`,
|
||||
id, origObj, thumbObj); err != nil {
|
||||
t.Fatalf("insert bindings: %v", err)
|
||||
}
|
||||
return thumbKey
|
||||
}
|
||||
|
||||
photo1 := auth.NewID()
|
||||
insertPhoto(photo1, "albums/"+a.ID+"/thumb/"+photo1+".webp")
|
||||
thumb1 := insertPhoto(photo1)
|
||||
|
||||
got, err := store.GetByID(ctx, a.ID)
|
||||
if err != nil || got.CoverThumbKey != "" {
|
||||
@@ -162,12 +175,12 @@ func TestAlbumCoverRepository(t *testing.T) {
|
||||
if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
|
||||
t.Fatalf("cover not set: %+v", got.CoverPhotoID)
|
||||
}
|
||||
if want := "albums/" + a.ID + "/thumb/" + photo1 + ".webp"; got.CoverThumbKey != want {
|
||||
t.Fatalf("cover thumb key = %q want %q", got.CoverThumbKey, want)
|
||||
if got.CoverThumbKey != thumb1 {
|
||||
t.Fatalf("cover thumb key = %q want %q", got.CoverThumbKey, thumb1)
|
||||
}
|
||||
|
||||
photo2 := auth.NewID()
|
||||
insertPhoto(photo2, "thumb-2")
|
||||
thumb2 := insertPhoto(photo2)
|
||||
ps.SetCoverIfEmpty(ctx, a.ID, photo2)
|
||||
got, _ = store.GetByID(ctx, a.ID)
|
||||
if got.CoverPhotoID == nil || *got.CoverPhotoID != photo1 {
|
||||
@@ -184,14 +197,13 @@ func TestAlbumCoverRepository(t *testing.T) {
|
||||
|
||||
ps.SetCoverIfEmpty(ctx, a.ID, photo2)
|
||||
list, err := store.ListForUser(ctx, owner.ID)
|
||||
if err != nil || len(list) != 1 || list[0].CoverThumbKey != "thumb-2" {
|
||||
if err != nil || len(list) != 1 || list[0].CoverThumbKey != thumb2 {
|
||||
t.Fatalf("list cover key: %v %+v", err, list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberRepository(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
owner := newUser(t, ctx, pool, "Owner")
|
||||
@@ -255,7 +267,6 @@ func TestMemberRepository(t *testing.T) {
|
||||
|
||||
func TestShareLinkRepository(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
owner := newUser(t, ctx, pool, "Owner")
|
||||
|
||||
Reference in New Issue
Block a user