Documentation
¶
Overview ¶
Package app implements the media bounded context's shared use cases: PhotoService (upload/delete/list/serve, generic over a caller-chosen PhotoClass) and ReaperService (reclaiming orphaned object-store bytes, generic over a caller-supplied set of PhotoClass sources). Both depend only on the ports declared in media/domain; implementations live in media/adapter.
Index ¶
- type DryRunResult
- type PhotoService
- func (s *PhotoService) Delete(ctx context.Context, householdID identity.HouseholdID, id domain.PhotoID) error
- func (s *PhotoService) List(ctx context.Context, householdID identity.HouseholdID) ([]*domain.Photo, error)
- func (s *PhotoService) OpenBytes(ctx context.Context, householdID identity.HouseholdID, id domain.PhotoID) (io.ReadCloser, string, error)
- func (s *PhotoService) RawServe(ctx context.Context, householdID identity.HouseholdID, id domain.PhotoID) (RawServeResult, error)
- func (s *PhotoService) Upload(ctx context.Context, householdID identity.HouseholdID, ...) (UploadResult, error)
- type RawServeResult
- type ReapableSource
- type ReaperResult
- type ReaperService
- type UploadResult
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type DryRunResult ¶
type DryRunResult struct {
OrphansWouldDelete map[domain.PhotoClass][]domain.StorageRef
}
DryRunResult summarizes one DryRun: exactly which orphaned objects each class's sweep WOULD delete — refs, not just a count — so an operator can inspect them before ever running the destructive Run.
type PhotoService ¶
type PhotoService struct {
// contains filtered or unexported fields
}
PhotoService handles uploading and deleting photos of ONE registered domain.PhotoClass: it streams the bytes behind the PhotoStore under that class, dedups by content hash, captures the EXIF date, and persists the photo metadata. class is fixed at construction (NewPhotoService), so a caller needing more than one class (e.g. Nestova's album AND chore-proof uploads) constructs one PhotoService per class rather than passing class per call — this is what keeps the upload use case identical for every consumer while still namespacing each class's bytes.
WRITES vs READS resolve to a PhotoStore differently (the mixed-state fix — see domain.PhotoStoreResolver's doc for the full argument): Upload always writes new bytes to writeBackend, the ONE backend this deployment is currently configured to write new photos to; every read (OpenBytes, RawServe, the EXIF-reopen inside Upload itself) resolves the STORE for the SPECIFIC ROW being read, via its own persisted Photo.StorageBackend — which may differ from writeBackend for a row written before an operator switched backends, or mid-migration. Resolving reads by the row's own backend, not a single fixed store, is what makes a mixed-state deployment's existing photos remain readable after a backend switch.
func NewPhotoService ¶
func NewPhotoService(class domain.PhotoClass, resolver domain.PhotoStoreResolver, writeBackend domain.StorageBackend, exif domain.ExifReader, photos domain.PhotoRepository) (*PhotoService, error)
NewPhotoService constructs the service bound to class — the domain.PhotoClass every Upload call namespaces its bytes under — and writeBackend, the StorageBackend Upload writes new photos to (see the type doc). Returns an error on a nil dependency, an invalid class, or an invalid writeBackend.
func (*PhotoService) Delete ¶
func (s *PhotoService) Delete(ctx context.Context, householdID identity.HouseholdID, id domain.PhotoID) error
Delete removes the photo's metadata row only (verifying it belongs to householdID) and returns domain.ErrPhotoNotFound for an unknown or cross-household id. It never touches the stored bytes.
PhotoService invariant: stored objects are immutable, content-addressed, and never synchronously deleted by this service, on this path or Upload's. Owning a photo's row is not the same as exclusively owning its ref: more than one row can share an exact ref (content-addressed dedup), and a concurrent re-upload of the same bytes could create a brand-new row referencing it between this row's metadata delete and a bytes delete here — Put is racing this call, not serialized after it. Deleting the object synchronously could therefore destroy bytes another row still depends on, with no cheap, race-free way from here to prove otherwise. The moment nothing references a ref, it becomes an orphan candidate; a storage reaper (ReaperService) finds and reclaims it after a grace window, rather than this service deleting it inline.
func (*PhotoService) List ¶
func (s *PhotoService) List(ctx context.Context, householdID identity.HouseholdID) ([]*domain.Photo, error)
List returns the household's photos.
func (*PhotoService) OpenBytes ¶
func (s *PhotoService) OpenBytes(ctx context.Context, householdID identity.HouseholdID, id domain.PhotoID) (io.ReadCloser, string, error)
OpenBytes streams a household photo's stored bytes after verifying ownership, returning domain.ErrPhotoNotFound for an unknown or cross-household id. It also returns the image content type (derived from the stored extension, which was set from the validated format at upload) so a caller can serve the bytes with an explicit Content-Type instead of letting the browser sniff them.
OpenBytes always streams through the calling process regardless of backend. See RawServe for the backend-aware seam a caller may use instead. The read resolves to photo's OWN persisted StorageBackend (see the type doc), not s.writeBackend, so a row written before a backend switch remains readable.
func (*PhotoService) RawServe ¶
func (s *PhotoService) RawServe(ctx context.Context, householdID identity.HouseholdID, id domain.PhotoID) (RawServeResult, error)
RawServe is the backend-aware serving seam behind a raw-bytes route: it resolves photo's OWN persisted StorageBackend (see the type doc — not necessarily s.writeBackend) and asks THAT store, via SupportsDirectURL, whether it can hand back a browser-navigable presigned URL (an S3-backed store) or must be Open-and-streamed through the calling process (LocalPhotoStore) — deciding here, once, at the service layer, keeps a caller's own handler thin. Returns domain.ErrPhotoNotFound for an unknown or cross-household id, exactly like OpenBytes.
func (*PhotoService) Upload ¶
func (s *PhotoService) Upload(ctx context.Context, householdID identity.HouseholdID, uploaderID identity.MemberID, r io.Reader) (UploadResult, error)
Upload streams r to storage under s.class, dedups by content hash, captures the EXIF date, and persists the photo attributed to uploaderID.
Stored objects are immutable, content-addressed, and never synchronously deleted by PhotoService — see the package-level invariant documented on Delete, which this shares. A failure after Put succeeds therefore leaves the object in place rather than rolling it back.
If a photo with the same content hash already exists for householdID, Upload returns that existing photo with Duplicate set — Put's write above was a harmless overwrite (content-addressed), so a re-drop of the same photo is a no-op. A duplicate detected by a concurrent upload racing this one (caught as domain.ErrDuplicatePhoto from the repository's unique index) resolves the same way.
It returns the storage layer's validation errors (ErrUnsupportedMediaType, ErrPhotoTooLarge, ErrInvalidPhoto) unchanged.
type RawServeResult ¶
type RawServeResult struct {
RedirectURL string
Body io.ReadCloser
ContentType string
}
RawServeResult is what PhotoService.RawServe returns: exactly one of RedirectURL (non-empty) or Body (non-nil) is set, mirroring the two ways a PhotoStore backend can serve bytes — see domain.PhotoStore. SupportsDirectURL's doc. RedirectURL is a presigned URL the caller should 302 the client to, bypassing the calling process entirely (an S3-backed store); Body is a stream the caller must copy through and Close (a local-filesystem store, which has no browser-navigable URL to hand back).
type ReapableSource ¶
type ReapableSource interface {
ListAllStorageRefs(ctx context.Context, backend domain.StorageBackend) ([]domain.StorageRef, error)
ExistsByStorageRef(ctx context.Context, ref domain.StorageRef, backend domain.StorageBackend) (bool, error)
}
ReapableSource is the narrow, class-scoped read surface a ReaperService needs from a registered domain.PhotoClass's own repository: which refs it still references, and whether one specific ref is still referenced. domain.PhotoRepository satisfies this structurally (no adapter needs to declare the assertion), and so does any other application-owned repository sharing the same two methods — e.g. a chore-proof-specific repository carrying its own extra retention methods this interface deliberately does not require (ISP): retention (deciding when to delete a ROW) is an application policy, not shared media plumbing, and stays entirely on the caller's side of Run.
type ReaperResult ¶
type ReaperResult struct {
OrphansDeleted map[domain.PhotoClass]int
}
ReaperResult summarizes one Run: how many orphaned objects the sweep deleted per class.
type ReaperService ¶
type ReaperService struct {
// contains filtered or unexported fields
}
ReaperService reclaims orphaned PhotoStore objects for an object-store backend: objects a repository's rows-only Delete leaves behind once nothing references them. It is generic over sources — one ReapableSource per domain.PhotoClass the caller wants swept — so a new class (a new consumer's own registered PhotoClass) is reaped by adding an entry to the map passed to NewReaperService, never by editing this package.
Never invoked automatically: reaping is destructive (it permanently deletes object-store bytes), so wiring an unattended timer is a caller-side decision this package does not make for it.
One pass per Run call: list every stored object of each registered class (lister.ListObjects), compute the set of StorageRefs still referenced by ANY source in sources (across every class, not just the class being swept — a row from one class's source can legitimately reference an object filed under a DIFFERENT class's own key prefix), and delete any object that is BOTH unreferenced AND older than graceWindow — an object younger than the grace window might be mid-upload (Put has written bytes but Create has not yet committed the referencing row), not genuinely orphaned; see domain.ObjectInfo.LastModified's doc.
This design makes "restore a week-old DB backup against the same bucket" safe by construction: a restored row's StorageRef reappears in the referenced-set computation on the very next Run, so an object the reaper has not yet reached is protected again before it is ever deleted.
TOCTOU note (deletion race): the referenced-set snapshot and the object listing are each taken once, up front, but a row can commit at any point after that snapshot. To narrow that window, Run re-checks EACH candidate individually, via a targeted ExistsByStorageRef query against every source, in the SAME loop iteration immediately before deleting it — this closes the gap down to the single instant between that final check and the delete call itself. That residual instant is NOT closed by this design; Run is intended to be invoked by an operator, never automatically, and MUST NOT be run concurrently with a database restore.
func NewReaperService ¶
func NewReaperService( store domain.PhotoStore, backend domain.StorageBackend, sources map[domain.PhotoClass]ReapableSource, graceWindow time.Duration, ) (*ReaperService, error)
NewReaperService constructs a ReaperService bound to backend — the StorageBackend store actually sweeps — over sources, one ReapableSource per domain.PhotoClass to walk. The lister this type's own doc promises ("app.ReaperService type-asserts for it") is derived from store itself via domain.ObjectLister, not accepted as a separate parameter: a caller-supplied lister bound to a DIFFERENT backend than store would make Run delete objects the wrong backend listed, and deriving it from store makes that mismatch unrepresentable. Returns an error instead of panicking on a nil store, a store that does not also implement domain.ObjectLister, an invalid backend, an empty or invalid-keyed sources map, or a non-positive graceWindow.
func (*ReaperService) DryRun ¶
func (r *ReaperService) DryRun(ctx context.Context, now time.Time) (DryRunResult, error)
DryRun previews exactly what the next Run call would delete — each class's orphan sweep's object refs — WITHOUT deleting anything. It is driven by the identical candidate-selection logic Run itself uses, so its output is a faithful preview of Run's next call, provided no row commits or object ages past the grace window in between (the type doc's TOCTOU note applies here too, as a staleness risk on the preview rather than a deletion race). Unlike Run, DryRun does not perform Run's per-candidate existsByStorageRef recheck: that recheck exists specifically to catch a row committing in the gap between the bulk snapshot and an ACTUAL delete call, and DryRun never deletes anything, so there is no such gap to protect.
func (*ReaperService) Run ¶
func (r *ReaperService) Run(ctx context.Context, now time.Time) (ReaperResult, error)
Run executes one reaper pass (see the type doc for the full order and the restore-safety argument) against now, and returns a summary. now is caller-supplied so a test can pin it rather than depending on the wall clock. A caller with its own application-level retention policy (e.g. deleting rows older than some cutoff) must run that pass BEFORE calling Run, so the rows it removes have already stopped protecting their objects by the time Run's referenced-set snapshot is taken.
type UploadResult ¶
UploadResult is the outcome of PhotoService.Upload: the photo that now represents the upload, and whether it was a re-drop of bytes the household already has (Duplicate) rather than a newly created row.