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:
@@ -23,9 +23,14 @@ type Store struct {
|
||||
|
||||
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||
|
||||
const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, p.storage_key,
|
||||
COALESCE(p.thumb_key, ''), COALESCE(p.preview_key, ''),
|
||||
p.original_filename, p.size, p.width, p.height, p.mime_type,
|
||||
// photoColumns selects photo rows together with the keys and physical facts of
|
||||
// their bound objects (original, thumb, preview) and the original's size,
|
||||
// dimensions and mime type.
|
||||
const photoColumns = `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),
|
||||
@@ -36,6 +41,14 @@ const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, p.storage_k
|
||||
FROM photo_tags pt JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.photo_id = p.id AND t.user_id = $2), '[]')::text`
|
||||
|
||||
const photoJoins = `
|
||||
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 scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||
var p models.Photo
|
||||
var exifText, tagsText string
|
||||
@@ -76,31 +89,68 @@ func setExif(p *models.Photo, raw string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, p *models.Photo) error {
|
||||
// CreateWithOriginal records a new photo together with its pending original
|
||||
// object, the original binding and a variant job — all in one transaction. The
|
||||
// caller stores the bytes and then activates the object. The metadata layer is
|
||||
// the ledger of record; the storage layer follows.
|
||||
func (s *Store) CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Object) error {
|
||||
exifJSON, err := json.Marshal(p.EXIF)
|
||||
if err != nil || len(exifJSON) == 0 {
|
||||
exifJSON = []byte("{}")
|
||||
}
|
||||
return s.pool.QueryRow(ctx,
|
||||
`INSERT INTO photos (id, album_id, uploader_id, storage_key, original_filename,
|
||||
size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb)
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(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
|
||||
}
|
||||
if err := tx.QueryRow(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::jsonb)
|
||||
RETURNING created_at`,
|
||||
p.ID, p.AlbumID, p.UploaderID, p.StorageKey, p.OriginalFilename,
|
||||
p.Size, p.Width, p.Height, p.MimeType, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON),
|
||||
).Scan(&p.CreatedAt)
|
||||
p.ID, p.AlbumID, p.UploaderID, p.OriginalFilename, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON),
|
||||
).Scan(&p.CreatedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(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.Exec(ctx,
|
||||
`INSERT INTO variant_jobs (photo_id) VALUES ($1) ON CONFLICT (photo_id) DO NOTHING`, p.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) SetVariants(ctx context.Context, id, thumbKey, previewKey string) error {
|
||||
_, err := s.pool.Exec(ctx,
|
||||
`UPDATE photos SET thumb_key = $2, preview_key = $3 WHERE id = $1`, id, thumbKey, previewKey)
|
||||
return err
|
||||
// DeleteUpload rolls back a failed upload: the photo (and its bindings and job)
|
||||
// plus the pending object row are removed.
|
||||
func (s *Store) DeleteUpload(ctx context.Context, photoID, objectID string) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photoID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM objects WHERE id = $1`, objectID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) GetByID(ctx context.Context, id, viewerID string) (*models.Photo, error) {
|
||||
return scanPhoto(s.pool.QueryRow(ctx,
|
||||
`SELECT `+photoColumns+`
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id`+photoJoins+`
|
||||
WHERE p.id = $1`, id, viewerID))
|
||||
}
|
||||
|
||||
@@ -118,7 +168,7 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
||||
}
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT `+photoColumns+`
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id`+photoJoins+`
|
||||
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))
|
||||
@@ -181,30 +231,36 @@ func (s *Store) UpdateAlbum(ctx context.Context, id, albumID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone creates a new photo row that references the same stored objects as src.
|
||||
// Clone creates a new photo row in albumID that references the same objects as
|
||||
// src. No storage operation is performed: the clone shares the physical files.
|
||||
func (s *Store) 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("{}")
|
||||
}
|
||||
_, err = s.pool.Exec(ctx,
|
||||
`INSERT INTO photos (id, album_id, uploader_id, storage_key, thumb_key, preview_key,
|
||||
original_filename, size, width, height, mime_type, taken_at, latitude, longitude, caption, exif)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb)`,
|
||||
id, albumID, uploaderID, src.StorageKey, nullIfEmpty(src.ThumbKey), nullIfEmpty(src.PreviewKey),
|
||||
src.OriginalFilename, src.Size, src.Width, src.Height, src.MimeType,
|
||||
src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON))
|
||||
return err
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(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::jsonb)`,
|
||||
id, albumID, uploaderID, src.OriginalFilename, src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(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(ctx)
|
||||
}
|
||||
|
||||
// StorageKeyRefCount counts photo rows (any state) referencing the stored object.
|
||||
func (s *Store) StorageKeyRefCount(ctx context.Context, storageKey string) (int, error) {
|
||||
var n int
|
||||
err := s.pool.QueryRow(ctx, `SELECT count(*) FROM photos WHERE storage_key = $1`, storageKey).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// HardDelete permanently removes the photo row.
|
||||
// HardDelete permanently removes the photo row. Bound objects are not touched
|
||||
// here; the caller marks orphaned objects for collection.
|
||||
func (s *Store) HardDelete(ctx context.Context, id string) error {
|
||||
tag, err := s.pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
@@ -223,7 +279,7 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
|
||||
`SELECT `+photoColumns+`, 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
|
||||
LEFT JOIN albums a ON a.id = p.album_id`+photoJoins+`
|
||||
WHERE p.deleted_at IS NOT NULL
|
||||
AND (
|
||||
(a.id IS NOT NULL AND (a.owner_id = $1 OR EXISTS(
|
||||
@@ -362,13 +418,6 @@ func (s *Store) TagOwnedBy(ctx context.Context, tagID, userID string) (bool, err
|
||||
return ok, err
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func (s *Store) SetCoverIfEmpty(ctx context.Context, albumID, photoID string) {
|
||||
_, _ = s.pool.Exec(ctx,
|
||||
`UPDATE albums SET cover_photo_id = $2 WHERE id = $1 AND cover_photo_id IS NULL`,
|
||||
@@ -391,11 +440,16 @@ type backfillRow struct {
|
||||
StorageKey string
|
||||
}
|
||||
|
||||
// BackfillEXIF re-reads the original object of every photo with empty exif and
|
||||
// fills the exif/geo fields on the photo.
|
||||
func (s *Store) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
`SELECT id, storage_key FROM photos
|
||||
WHERE exif IS NULL OR exif = '{}'::jsonb
|
||||
ORDER BY created_at`)
|
||||
`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 = '{}'::jsonb
|
||||
ORDER BY p.created_at`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user