# Changelog
## [0.1.0] - 2026-04-24
Initial release.
### Added
- `attached` macro for Ecto schemas — generates a `belongs_to
:{name}_attached_original` association and expects a
`{name}_attached_original_id` UUID FK column. Configurable per field
via `:foreign_key` or globally via
`config :attached, :default_foreign_key_suffix:`.
- `put_attached/3` — attach files inside a changeset via
`prepare_changes/2`, transactional with the parent insert/update.
Accepts `%Plug.Upload{}`, any map with `:path` (e.g. from
`Phoenix.LiveView.consume_uploaded_entries/3`), an existing `%Original{}`
(re-attach without storage I/O), or `nil` (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. Raises `ArgumentError` if `:variants` is 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 manual `Repo.preload` to 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 as `purge/2` but via an Oban job. Enqueues
inside the current transaction, so a rollback cancels the job too.
- `attached_originals` table — stores files with `key`, `filename`,
`storage_backend`, `content_type`, `byte_size`, `checksum`, `owner_table`,
`owner_field`, `metadata` (JSON).
- `attached_variants` table — 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.Variants` context — `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 of `path_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 since
`quality:` 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 `:ok` or `{:error, reason}`. Anonymous functions are not accepted
(non-deterministic digests).
- `Attached.Processors.Transformers` registry — transformers declare `accept?/2`
with `(input_content_type, output_content_type)` pairs. Built-in: `Vix` and
`ImageMagick` (both `image/* → image/*`). Non-image transforms
(e.g. `application/pdf → text/plain`) are a first-class extension point via
`Attached.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.ExtractMetadataWorker` runs the first accepting extractor
and merges results into `original.metadata`: `width`/`height` for images,
`width`/`height`/`duration`/`aspect_ratio`/`angle`/`audio`/`video` for
video, `duration`/`bit_rate` for audio.
- `Attached.Originals` context — `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 whose
`owner_table`/`owner_field` no 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 via
`Attached.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. Respects `config :attached, :default_foreign_key_suffix:`.
- `Attached.Ecto.Migration.rename/2` — keeps `owner_table`/`owner_field` in
sync when renaming fields or tables. Call it alongside Ecto's own `rename`
in your migration, otherwise orphan detection silently breaks.
- `.formatter.exs` exports `attached: 1, 2` as `locals_without_parens` and
imports `:ecto`/`:ecto_sql` formatter configs.
- `Attached.Test` — test helpers: `setup_storage!/1` (configures Disk backend
against a tmp dir with `at_exit` cleanup) and `attach!/3` (bypasses the
changeset flow for test fixtures).