domain

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: AGPL-3.0 Imports: 10 Imported by: 0

Documentation

Overview

Package domain holds the media bounded context's entities, value objects, and ports: Photo, PhotoClass, StorageBackend, and the PhotoStore/ PhotoRepository/ObjectLister/ExifReader ports adapters implement.

This package is keyed on nestcore/identity's HouseholdID/MemberID, not on either app's own household package — see the household/adapter/doc.go precedent this mirrors. It imports nothing from either app: PhotoClass is an app-registered value type (see photo_class.go) rather than a fixed enum, precisely so a new consumer never needs to modify this package to add its own upload purpose.

What stays app-side

A photo ALBUM (Nestova) and a chore-completion PROOF photo (Nestova) are business domains built on top of this package's ports, not part of it; likewise Nestorage's item-to-photo join. This package owns only the shared plumbing: upload validation, EXIF handling, storage backends, and the photo row itself (id, household, storage location, and the server-verified upload facts) — see adapter's package doc for the exact table shape every consumer's own migration must provide.

Index

Constants

View Source
const (
	ContentTypeJPEG = "image/jpeg"
	ContentTypePNG  = "image/png"
	ContentTypeWebP = "image/webp"
)

Accepted image content types — the upload accept-list. Both Photo.Validate and the adapter's storage-extension mapping key off these constants, so there is a single source of truth for "what image type does the upload path accept."

Variables

View Source
var (
	// ErrPhotoNotFound is returned when a photo does not exist (or belongs
	// to another household).
	ErrPhotoNotFound = errors.New("media: photo not found")
	// ErrInvalidPhoto is returned by Photo.Validate for a malformed photo,
	// and by the upload-validation path for bytes that fail to decode as
	// their sniffed content type.
	ErrInvalidPhoto = errors.New("media: invalid photo")
	// ErrUnsupportedMediaType is returned when an upload's content type is
	// not an accepted image format. Callers typically map it to 415.
	ErrUnsupportedMediaType = errors.New("media: unsupported media type")
	// ErrPhotoTooLarge is returned when an upload exceeds the configured
	// size limit. Callers typically map it to 413.
	ErrPhotoTooLarge = errors.New("media: photo exceeds the maximum size")
	// ErrDuplicatePhoto is returned by PhotoRepository.Create when a photo
	// with the same content hash already exists for the household (the
	// canonical photo_household_content_hash_uniq index — see the adapter
	// package doc for the expected table shape). PhotoService resolves it
	// by fetching and returning the existing photo instead of surfacing an
	// error, so callers never see it directly.
	ErrDuplicatePhoto = errors.New("media: duplicate photo content")
)

Photo errors.

View Source
var ErrStoreNotConfigured = errors.New("media: no PhotoStore configured for this backend")

ErrStoreNotConfigured is returned by PhotoStoreResolver.Resolve when this deployment never constructed a PhotoStore for the requested StorageBackend — a genuine misconfiguration, never a per-request condition a caller can route around, so it propagates as an opaque error rather than being mapped to any user-facing 4xx.

Functions

func ValidContentHash

func ValidContentHash(hash string) bool

ValidContentHash reports whether hash is a well-formed content hash: a 64-character lowercase hex sha256, matching the canonical table's photo_content_sha256_format CHECK (see the adapter package doc). Exposed so a caller writing a raw content hash outside Photo.Validate's normal path — PhotoRepository.MigrateStorageBackend's contentHash argument is the only one today — validates against the exact same pattern, rather than each such caller keeping its own copy of the regexp.

Types

type ExifReader

type ExifReader interface {
	TakenAt(r RandomAccessReader) *time.Time
}

ExifReader extracts the EXIF capture time from a photo's bytes. TakenAt returns the capture time normalized to UTC, or nil when the image carries no usable EXIF date (a missing tag is not an error — the photo is simply stored without a taken_at). r must support random access because EXIF/TIFF fields are addressed by absolute byte offsets; a PhotoStore.Open result satisfies this directly, so no full-file buffering is needed just to extract the capture time.

type ObjectExister

type ObjectExister interface {
	ObjectExists(ctx context.Context, ref StorageRef) (bool, error)
}

ObjectExister reports whether an object already exists at a given StorageRef without downloading it (an S3 HeadObject, for S3PhotoStore) — a storage migrator's idempotency check before uploading a content-addressed object a DIFFERENT row (same household+class+hash) may have already migrated there. Only an object-store backend implements this; see ObjectLister's identical asymmetric-implementation rationale (LocalPhotoStore does not, since a migrator never checks existence against the local backend).

type ObjectInfo

type ObjectInfo struct {
	// Key is the object's storage key — directly comparable to a
	// PhotoRepository row's StorageRef, since both are opaque strings drawn
	// from the same key scheme.
	Key StorageRef
	// LastModified is when the backend last wrote this object — the grace
	// window a storage reaper checks before treating an unreferenced object
	// as safe to delete: an object younger than the grace window might be
	// mid-upload (Put has written the bytes but PhotoRepository.Create has
	// not yet committed the referencing row) rather than genuinely
	// orphaned.
	LastModified time.Time
}

ObjectInfo is one stored object as ObjectLister reports it.

type ObjectLister

type ObjectLister interface {
	// ListObjects returns every stored object under class's namespace,
	// across every household — the reaper operates bucket-wide, not
	// household-scoped, since orphan reclamation is a storage-housekeeping
	// concern, not a tenant-facing one. Returns an empty slice (not an
	// error) when the class has no stored objects yet.
	ListObjects(ctx context.Context, class PhotoClass) ([]ObjectInfo, error)
}

ObjectLister lists a PhotoStore backend's raw stored objects, class by class, for a storage reaper: only an object-store backend (S3PhotoStore) implements it. LocalPhotoStore does not — a local filesystem's own orphaned files are not a reaper's target for now, since only an object store can be listed cheaply without walking a filesystem tree the caller may not even have direct access to.

A PhotoStore backend that also implements ObjectLister opts into being reapable; app.ReaperService type-asserts for it rather than this being folded into the base PhotoStore interface, keeping PhotoStore itself minimal (ISP) for the common case of a caller that only ever needs Put/Open/Delete/URL.

type Photo

type Photo struct {
	ID             PhotoID
	HouseholdID    identity.HouseholdID
	StorageRef     StorageRef
	ContentHash    string
	SizeBytes      int64
	ContentType    string
	TakenAt        *time.Time
	UploadedBy     *identity.MemberID
	CreatedAt      time.Time
	StorageBackend StorageBackend
}

Photo is one household image. StorageRef points at the bytes behind the PhotoStore; ContentHash is the hex sha256 of those bytes (computed once, while streaming the upload to storage) and is what content-hash dedup keys on. SizeBytes and ContentType are the other server-verified upload facts. TakenAt is the EXIF capture time (UTC) when the upload carried one. UploadedBy is the member who added it, nilled (not deleted) if that member is removed so the photo survives. StorageBackend is populated by PhotoRepository.Create from the repository's own configured backend, never by the caller — see that method's doc — so it is the zero value on a Photo the caller is still building, exactly like CreatedAt.

func (Photo) Validate

func (p Photo) Validate() error

Validate reports whether the photo is well-formed, wrapping ErrInvalidPhoto. ContentHash, SizeBytes, and ContentType are all required in their canonical server-verified shape because every photo PhotoStore. Put produces has them — a violation here signals a PhotoStore implementation bug, not a legitimate legacy photo.

type PhotoClass

type PhotoClass struct {
	// contains filtered or unexported fields
}

PhotoClass partitions the PhotoStore's key namespace so bytes uploaded for one purpose (e.g. a chore-completion proof photo) can never collide with, or be reinterpreted as, another purpose's bytes (e.g. a household album photo) — every PhotoStore.Put call takes a PhotoClass, and the resulting StorageRef is namespaced by it (see the adapter package's buildStorageKey).

PhotoClass is an APP-REGISTERED value type, not a fixed enum (OCP): each consuming application calls RegisterPhotoClass, typically once per class in a package-level var, to mint the values it uploads under — Nestova registers its album and chore-proof classes, Nestorage registers its item-photo class — without ever needing to modify this package. The registered name becomes the class's own storage-key prefix (see classKeyPrefix in the adapter package), so two applications' classes can never collide as long as each registers a distinct name.

func RegisterPhotoClass

func RegisterPhotoClass(name string) PhotoClass

RegisterPhotoClass registers and returns a new PhotoClass under name. Called at most once per name, normally from a package-level var in the registering application's composition root — a duplicate or malformed name is a programming error, not a runtime condition a caller can recover from, so both panic rather than returning an error. name must be lowercase ASCII letters, digits, or underscores, starting with a letter.

func (PhotoClass) String

func (c PhotoClass) String() string

String returns the registered name, or "unspecified" for the zero value — used in error messages and as the class's storage-key prefix (see classKeyPrefix), never as a persisted database value (nothing serializes a PhotoClass across a boundary today).

func (PhotoClass) Valid

func (c PhotoClass) Valid() bool

Valid reports whether c is a class actually registered via RegisterPhotoClass — the zero value and any hand-built PhotoClass are invalid, since a class this package never registered was never assigned a storage-key prefix.

type PhotoID

type PhotoID uuid.UUID

PhotoID uniquely identifies a photo.

func NewPhotoID

func NewPhotoID() PhotoID

NewPhotoID returns a new time-ordered (UUIDv7) photo id, which gives better B-tree index locality than random v4 ids. uuid.NewV7 only errors if the crypto random source is unavailable — the same failure under which uuid.New itself panics — so Must is appropriate here.

func ParsePhotoID

func ParsePhotoID(s string) (PhotoID, error)

ParsePhotoID parses a canonical UUID string into a PhotoID.

func (PhotoID) String

func (id PhotoID) String() string

String returns the canonical UUID string.

type PhotoReader

type PhotoReader interface {
	RandomAccessReader
	io.Closer
}

PhotoReader is what PhotoStore.Open returns: a RandomAccessReader that must also be closed. A local-file adapter satisfies this for free (*os.File already supports ReadAt/Seek); an object-store adapter is free to satisfy it however it needs to (e.g. buffering a fetched range), since that choice stays internal to the adapter.

type PhotoRepository

type PhotoRepository interface {
	Create(ctx context.Context, photo *Photo) error
	Get(ctx context.Context, id PhotoID) (*Photo, error)
	FindByContentHash(ctx context.Context, householdID identity.HouseholdID, hash string) (*Photo, error)
	ListByHousehold(ctx context.Context, householdID identity.HouseholdID) ([]*Photo, error)
	Delete(ctx context.Context, id PhotoID) error

	// ListAllStorageRefs returns the StorageRef of every photo row stamped
	// with backend, across every household — a storage reaper's source of
	// truth for "which objects of THIS backend are still referenced."
	// backend is explicit, not implicitly bound to the repository's own
	// configured write backend: a repository instance serves reads across
	// every backend rows may be stamped with, mid-migration mixed state
	// included. Returns an empty slice (not an error) when there are no
	// matching photos.
	ListAllStorageRefs(ctx context.Context, backend StorageBackend) ([]StorageRef, error)

	// ExistsByStorageRef reports whether any photo row STAMPED WITH backend
	// currently references ref, across every household — a targeted,
	// single-ref query a storage reaper runs immediately before deleting an
	// apparently-orphaned object, narrowing the TOCTOU window between a bulk
	// ListAllStorageRefs snapshot and the delete.
	ExistsByStorageRef(ctx context.Context, ref StorageRef, backend StorageBackend) (bool, error)

	// ListByBackend returns up to limit rows stamped with backend, ordered
	// by id ascending, whose id is strictly greater than afterID — a
	// storage migrator's keyset-paginated batch source for "every
	// local-backend photo still to move." Pass the zero PhotoID (
	// ParsePhotoID never produces one) to fetch the first page. Returns an
	// empty (not nil) slice once no more rows match.
	ListByBackend(ctx context.Context, backend StorageBackend, afterID PhotoID, limit int) ([]*Photo, error)

	// MigrateStorageBackend flips ONE local-backend row onto newBackend,
	// writing newRef as the row's new StorageRef and, ONLY when the row's
	// content_sha256 is currently NULL, backfilling it with contentHash.
	// The update is conditioned on the row's CURRENT storage_backend being
	// 'local' — an idempotency guard: done reports whether a row actually
	// matched and was flipped.
	MigrateStorageBackend(ctx context.Context, id PhotoID, newRef StorageRef, newBackend StorageBackend, contentHash string) (done bool, err error)
}

PhotoRepository persists photo metadata (not the bytes) against the canonical "photo" table every consumer's own migration provides (see the adapter package doc for the exact shape). Get returns ErrPhotoNotFound for an unknown id; a Create with an unknown HouseholdID returns identity.ErrHouseholdNotFound, an unknown UploadedBy returns identity.ErrMemberNotFound, and a content hash that collides with another household photo returns ErrDuplicatePhoto (all mapped from the tenant/unique constraint violations by the adapter). ListByHousehold returns an empty slice (not an error) when none match. FindByContentHash returns ErrPhotoNotFound when no household photo carries that hash — the expected "not a duplicate" outcome, not an exceptional one.

Every implementation is constructed bound to ONE StorageBackend, matching the composition root's single-backend-per-deployment selection — Create stamps that value onto photo.StorageBackend, ignoring whatever the caller may have left on the struct, so the column always reflects which backend genuinely wrote the bytes, never the column's DEFAULT by omission.

Compatibility: this port is shared API consumed by independently versioned binaries — additive-only. New capabilities arrive as new, narrowly-scoped interfaces (ISP) callers type-assert for (see ObjectLister, ObjectExister, RawObjectWriter), never by widening this interface itself.

type PhotoStore

type PhotoStore interface {
	Put(ctx context.Context, householdID identity.HouseholdID, class PhotoClass, r io.Reader) (PutResult, error)
	Open(ctx context.Context, ref StorageRef) (PhotoReader, error)
	Delete(ctx context.Context, ref StorageRef) error

	// URL returns a locator for ref's stored bytes, or ErrPhotoNotFound when
	// ref is unknown. ttl bounds how long the locator stays valid; a
	// backend that cannot expire what it returns (LocalPhotoStore) treats
	// ttl as advisory and ignores it. A non-positive ttl asks the backend
	// to apply its own configured default rather than the zero-duration URL
	// a literal interpretation would produce.
	//
	// The two backends this port is designed for answer very differently:
	// an object-store adapter returns a presigned URL a client can fetch
	// directly, scoped by ref alone. LocalPhotoStore has no such direct
	// link — a caller keyed by its own domain id (so it can check household
	// ownership before ever touching the store) should not call URL at all;
	// ref alone never carries enough context to reconstruct a
	// tenant-checked route. See SupportsDirectURL for how a caller decides
	// which behavior to expect without knowing the concrete backend type.
	URL(ctx context.Context, ref StorageRef, ttl time.Duration) (string, error)

	// SupportsDirectURL reports whether URL returns a browser-navigable
	// locator a caller may safely redirect a client to (an object-store
	// backend's presigned GET) as opposed to LocalPhotoStore's
	// non-navigable stable-string locator (see URL's doc). A caller reads
	// this once, at request time, to decide whether to redirect or
	// Open-and-stream through its own process — asking the store itself,
	// rather than threading a separately-configured "which backend" flag
	// through every consumer, means the two can never drift out of sync.
	SupportsDirectURL() bool
}

PhotoStore persists and serves photo bytes behind a swappable port (a local filesystem adapter, or an object-store adapter — S3PhotoStore). Put streams r to storage — it never buffers the whole upload in memory — sniffing the true content type from the bytes themselves (a caller-declared type is never trusted), hashing and size-capping as it copies. class namespaces the resulting StorageRef so bytes uploaded for one purpose can never collide with, or be served as, another's — the calling context chooses class, never the store. Open streams the bytes back (for serving, and for EXIF extraction, which needs the RandomAccessReader half of PhotoReader); Delete removes them; URL returns a locator for already-stored bytes.

Put error contract: ErrUnsupportedMediaType when the sniffed type is not an accepted image format, ErrPhotoTooLarge when r exceeds the configured limit, and ErrInvalidPhoto when the bytes do not actually decode as the sniffed type. Open and URL return ErrPhotoNotFound when ref is unknown.

Compatibility: this port, like PhotoRepository, is shared API consumed by independently versioned binaries — additive-only. A new capability (e.g. ObjectLister below) is a new, narrowly-scoped interface a caller type-asserts for, never a widening of this one.

type PhotoStoreResolver

type PhotoStoreResolver interface {
	// Resolve returns the PhotoStore constructed for backend, or
	// ErrStoreNotConfigured when this deployment never constructed one for
	// it — e.g. a local-only deployment encountering a row stamped 's3'.
	Resolve(backend StorageBackend) (PhotoStore, error)
}

PhotoStoreResolver resolves READS to the PhotoStore that actually holds a given row's bytes, keyed by the row's own PERSISTED StorageBackend — NOT by whichever backend is currently configured for NEW writes. This distinction exists because "one backend, app-wide" write selection does not retroactively migrate already-stored rows: a deployment that switches its configured backend (or an in-progress migration) can genuinely hold both local-backed and s3-backed rows at once, and every read must reach whichever store actually wrote that SPECIFIC row's bytes — resolving by the row's own backend, not the deployment's current write target, is what makes that possible.

A caller is typically constructed with a resolver PLUS a separate write-target StorageBackend: writes always resolve the write-target backend (new bytes go to wherever this deployment currently writes); every other PhotoStore method resolves the row's own backend.

type PutResult

type PutResult struct {
	Ref         StorageRef
	ContentHash string
	SizeBytes   int64
	ContentType string
}

PutResult is the outcome of storing an upload: the ref to persist on the Photo plus the facts PhotoStore.Put computes while streaming the bytes to storage — ContentHash (sha256, for content-hash dedup), SizeBytes, and the server-sniffed ContentType. ContentType is authoritative: it is derived from the bytes themselves, never from a caller-supplied claim.

type RandomAccessReader

type RandomAccessReader interface {
	io.Reader
	io.ReaderAt
	io.Seeker
}

RandomAccessReader is a read source that also supports offset-based access — Read for sequential consumption, ReadAt/Seek for formats (EXIF/TIFF) whose internal structure is addressed by absolute byte offsets. Sharing this between PhotoStore.Open and ExifReader.TakenAt lets EXIF extraction read directly from the stored file instead of first buffering it into a []byte.

type RawObjectWriter

type RawObjectWriter interface {
	PutAt(ctx context.Context, ref StorageRef, contentType string, r io.Reader) error
}

RawObjectWriter uploads bytes the caller has ALREADY validated (content sniffed, decoded, hashed, size-capped — exactly what PhotoStore.Put's own validation does once already, at the photo's original local upload) directly to ref, verbatim, with no second validation pass. A storage migrator is the only intended caller: re-running Put's full validation pipeline a second time for every migrated photo would repeat work Put's contract does not require repeating, and — more fundamentally — Put always computes its OWN content-addressed key from the bytes it streams, which a migrator cannot use directly, since the object must land at a SPECIFIC key (the class-namespaced key the migrator itself derives from the row's household/class/content-hash), not wherever Put's internal hashing would place it. Only an object-store backend implements this, mirroring ObjectExister's identical asymmetry.

type StorageBackend

type StorageBackend string

StorageBackend records which domain.PhotoStore implementation persisted a photo's bytes — a repository-level fact (see PhotoRepository's Create doc), not something a caller chooses per photo: the composition root selects one backend, app-wide, at startup, and every repository instance is constructed bound to that same value, which Create stamps onto every row it writes.

const (
	StorageBackendLocal StorageBackend = "local"
	StorageBackendS3    StorageBackend = "s3"
)

StorageBackend values, mirroring the storage_backend CHECK constraint every consumer's own migration is expected to declare (see adapter's package doc for the exact table shape).

func ParseStorageBackend

func ParseStorageBackend(s string) (StorageBackend, error)

ParseStorageBackend parses a persisted storage_backend column value, rejecting anything a CHECK constraint should never have let through — a defensive boundary check rather than trusting the database blindly.

func (StorageBackend) String

func (b StorageBackend) String() string

String returns b's storage/column spelling.

func (StorageBackend) Valid

func (b StorageBackend) Valid() bool

Valid reports whether b is a known StorageBackend.

type StorageRef

type StorageRef string

StorageRef is an opaque key identifying a photo's bytes in the PhotoStore. The bytes are never stored in the database.

func (StorageRef) String

func (r StorageRef) String() string

String returns the ref's string value.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL