[0.2.0] - 2026-06-11
Changed — BREAKING
Storage backends are now named instances in a registry, replacing the single global backend pick (
config :attached, :storage_backend, Module+ per-module config keys like:disk/:s3):# Before config :attached, storage_backend: Attached.StorageBackends.S3, s3: [bucket: "my-bucket", ...] # After config :attached, storage_backends: [ s3_main: {Attached.StorageBackends.S3, bucket: "my-bucket", ...} ]The default instance is the only registry entry, or — with several entries — the one named by
config :attached, :default_storage_backend. Old config keys (:storage_backend,:service,:disk,:s3) raise with migration instructions instead of silently falling back to Disk. This makes multiple instances of the same backend possible (e.g. two S3 buckets) and is the groundwork for the planned mirror backend and per-row dispatch.Attached.StorageBackends.Behaviourcallbacks take the instance's config keyword as their first argument (upload(config, key, source_path, opts),download(config, key), ...). Custom backends must be updated; backend modules no longer read global application config.The
storage_backendcolumn onattached_originalsrecords the instance name (e.g."local","s3_main") instead of the module name ("Attached.StorageBackends.Disk") — mirrors Active Storage'sservice_name. Existing rows are not migrated automatically; the column is informational only (no dispatch reads it yet).Attached.Test.setup_storage!/1now configures the registry with a single Disk instance named:local.
Added
Attached.StorageBackends.S3— storage backend for Amazon S3 and S3-compatible services (MinIO, Cloudflare R2, DigitalOcean Spaces) via the optionalreqdependency (already included in new Phoenix apps). SigV4 signing is implemented in-house and verified against the official AWS test vectors — no AWS SDK needed. Presigned GET URLs (Attached.Web.Plugnot involved), ListObjectsV2-baseddelete_prefixed/1with pagination, STS session tokens, and optionalresponse-content-typeon presigned URLs resolved from the original/variant row. Path-style addressing via the:endpointoption for S3-compatibles.- S3 integration suite that boots a local Garage server and exercises the
full backend — including acceptance of our presigned URLs by a real S3
implementation. Runs as part of
mix testwhenever thegaragebinary is available (the dev shell provides it), excluded otherwise. - Direct-upload groundwork:
Attached.StorageBackends.direct_upload_url/2returns a URL (plus required headers) for uploading a key straight from the browser via HTTP PUT. S3 presigns the PUT withcontent-md5,content-type, andcontent-lengthpinned in the signature; Disk serves a purpose-bound token handled by a newPUT /originals/:tokenroute inAttached.Web.Plug(with optional:max_upload_sizeand Content-MD5 verification).Attached.Web.Signertokens now carry a purpose, so download URLs can never be replayed as uploads.
Changed
- Orphan purging (
PurgeOrphansWorker,purge_by_owner_group/2) now skips orphans younger thanconfig :attached, :orphan_grace_period(default 48 hours,0disables), so originals created ahead of their attachment — e.g. direct uploads in flight — survive the sweep.list_orphans/...andcount_orphans/...still report all current orphans regardless of age.
Fixed
Attached.Variants.process/3is now actually idempotent under concurrency: two simultaneous callers for the same uncached variant no longer crash the loser withEcto.ConstraintError— it returns the winner's cached row.resize_and_padin the Vix transformer now pads to the exact target dimensions with a transparent background (it behaved likeresize_to_fit), matching the ImageMagick backend.path_for/1now has an explicitnilclause, resolving an Elixir 1.20 type warning when tests passnilto verify the security guard.- Logger level set to
:warningin test env, suppressing debug query output. - All DB-touching tests migrated from
ExUnit.Case+ manual sandbox checkout toAttached.DataCase, eliminating sandbox ownership races. ImageMagick.metadata/1now returns%{}early for nonexistent paths viaFile.exists?/1, avoiding a noisyidentifystderr error in tests.- ImageMagick metadata tests use a JPEG fixture with an embedded EXIF orientation
tag, eliminating the
unknown image propertystderr warning. VixTestnow usesCode.ensure_loaded?(Vix)instead ofCode.ensure_loaded?(Vix.Vips.Image)to avoid NIF load failure at compile time causing tests to be incorrectly skipped.
[0.1.1] - 2026-06-08
Fixed
.formatter.exswas missing from the published Hex package, preventingimport_deps: [:attached]from working for consumers.
[0.1.0] - 2026-04-24
Initial release.
Added
attachedmacro for Ecto schemas — generates abelongs_to :{name}_attached_originalassociation and expects a{name}_attached_original_idUUID FK column. Configurable per field via:foreign_keyor globally viaconfig :attached, :default_foreign_key_suffix:.put_attached/3— attach files inside a changeset viaprepare_changes/2, transactional with the parent insert/update. Accepts%Plug.Upload{}, any map with:path(e.g. fromPhoenix.LiveView.consume_uploaded_entries/3), an existing%Original{}(re-attach without storage I/O), ornil(no-op).Attached.url/2,3— URL to the original file or a named variant. Variant URLs trigger lazy generation on first call and return the cached URL on all subsequent calls. RaisesArgumentErrorif:variantsis not preloaded on the original.Attached.attached?/2— boolean attachment check.Attached.with_attached/2— preloads the original and its variants in one shot. Use this instead of manualRepo.preloadto avoid a second round-trip per variant URL call.Attached.upload_original/2— standalone original upload outside the changeset flow (e.g. Trix inline image uploads before an article is saved).Attached.purge/2— synchronously deletes the original record, all variant records, and all associated storage files.Attached.purge_later/2— same aspurge/2but via an Oban job. Enqueues inside the current transaction, so a rollback cancels the job too.attached_originalstable — stores files withkey,filename,storage_backend,content_type,byte_size,checksum,owner_table,owner_field,metadata(JSON).attached_variantstable — cached derivations. Fields:original_id(FK,on_delete: :delete_all),name,transform_digest,content_type,byte_size,checksum,metadata.UNIQUE(original_id, transform_digest).Attached.Variantscontext —list/1,get/2,get!/2,count/1,paginate/1,process/3,purge!/1,delete_for!/1,get_for/2,path_for/2,3,get_by_path/1,previewable?/1,preview_url/1,transforms_for/3,transform_digest/1.Attached.Variants.path_for/2,3— single source of truth for variant storage paths:"_variants/#{parent.key}-#{name}-#{digest[0..3]}". Variants live under_variants/so originals and variants can be handled separately in listings, backups, and cleanup sweeps.Attached.Variants.get_by_path/1— reverse ofpath_for; used by the plug to resolve the content type of a variant URL.Variant
quality:option (integer 1–100) — applied to the encoder at write time. Different quality values produce distinct cached variants sincequality:is included in the transform digest.Variant
fn:option — bypass the built-in transformer with a named function capture. The function receives(input_path, transforms, output_path)and must return:okor{:error, reason}. Anonymous functions are not accepted (non-deterministic digests).Attached.Processors.Transformersregistry — transformers declareaccept?/2with(input_content_type, output_content_type)pairs. Built-in:VixandImageMagick(bothimage/* → image/*). Non-image transforms (e.g.application/pdf → text/plain) are a first-class extension point viaAttached.Processors.Transformers.Behaviour.Attached.Processors.ImagePreviewers— fallback stage for image-targeted variants when no direct transformer accepts the MIME pair. Built-in previewers: PDF (pdftoppm / mutool), video (ffmpeg), EPUB (gnome-epub-thumbnailer).Attached.Processors.MetadataExtractors— async analysis after upload.Attached.Originals.ExtractMetadataWorkerruns the first accepting extractor and merges results intooriginal.metadata:width/heightfor images,width/height/duration/aspect_ratio/angle/audio/videofor video,duration/bit_ratefor audio.Attached.Originalscontext —list/1,get/2,get!/2,get_by_key/2,count/1,paginate/1,create_from_upload!/2,create_from_file!/2,create_from_stream!/2,update_metadata!/2,purge!/1,purge_later/1,list_owner_groups/0,list_orphan_groups/0,list_orphans/4,count_orphans/0,2,purge_orphans_later/0,purge_by_owner_group/2,extract_metadata_later/1,get_owner/1.Attached.Originals.Stats— aggregate queries for dashboards:overview/0,by_content_type/0,by_owner_group/0,by_storage_backend/0.Attached.Originals.PurgeOrphansWorker— finds originals whoseowner_table/owner_fieldno longer reference a live FK row and purges them. Schedule via Oban cron:config :my_app, Oban, plugins: [{Oban.Plugins.Cron, crontab: [ {"0 3 * * *", Attached.Originals.PurgeOrphansWorker} ]}]Attached.Variants.VariantTransformWorker— Oban worker for eager variant pre-warming. Args:{original_id, record_module, field, variant}. Resolves transforms from the schema at perform time, computes the digest itself — no transform serialization needed.Attached.StorageBackends.Disk— local filesystem backend. Serves files viaAttached.Web.Plug(forward "/storage", Attached.Web.Plug).Attached.StorageBackends.Behaviour— implement to add custom backends.mix attached.install— generates the initial migration (both tables). Future schema changes ship as versioned migrations:Attached.Ecto.Migration.up(version: N).mix attached.gen.migration SchemaModule field— generates a per-attachment FK migration. Respectsconfig :attached, :default_foreign_key_suffix:.Attached.Ecto.Migration.rename/2— keepsowner_table/owner_fieldin sync when renaming fields or tables. Call it alongside Ecto's ownrenamein your migration, otherwise orphan detection silently breaks..formatter.exsexportsattached: 1, 2aslocals_without_parensand imports:ecto/:ecto_sqlformatter configs.Attached.Test— test helpers:setup_storage!/1(configures Disk backend against a tmp dir withat_exitcleanup) andattach!/3(bypasses the changeset flow for test fixtures).