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
@@ -0,0 +1,39 @@
-- Physical objects stored in S3. Keys are opaque and never encode album, photo
-- or any other metadata entity. The row is the ledger entry that governs the
-- lifecycle of the corresponding blob.
CREATE TABLE IF NOT EXISTS objects (
id text PRIMARY KEY,
storage_key text NOT NULL UNIQUE,
mime_type text NOT NULL DEFAULT '',
size bigint NOT NULL DEFAULT 0,
width integer NOT NULL DEFAULT 0,
height integer NOT NULL DEFAULT 0,
checksum text NOT NULL DEFAULT '',
state text NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending', 'ready', 'deleting')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS objects_state_created_idx ON objects (state, created_at);
-- Binding of a photo to its objects by role. The role is a property of the
-- binding, not of the object itself.
CREATE TABLE IF NOT EXISTS photo_objects (
photo_id text NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
object_id text NOT NULL REFERENCES objects(id) ON DELETE RESTRICT,
role text NOT NULL CHECK (role IN ('original', 'thumb', 'preview')),
PRIMARY KEY (photo_id, role)
);
CREATE INDEX IF NOT EXISTS photo_objects_object_idx ON photo_objects (object_id);
-- Durable queue of pending variant generation work, one job per photo.
CREATE TABLE IF NOT EXISTS variant_jobs (
photo_id text PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE,
attempts integer NOT NULL DEFAULT 0,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
last_error text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS variant_jobs_due_idx ON variant_jobs (next_attempt_at);