[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).