# Changelog
## [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.Behaviour` callbacks 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_backend` column on `attached_originals` records the instance
name (e.g. `"local"`, `"s3_main"`) instead of the module name
(`"Attached.StorageBackends.Disk"`) — mirrors Active Storage's
`service_name`. Existing rows are not migrated automatically; the column is
informational only (no dispatch reads it yet).
- `Attached.Test.setup_storage!/1` now 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
optional `req` dependency (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.Plug` not involved), ListObjectsV2-based
`delete_prefixed/1` with pagination, STS session tokens, and optional
`response-content-type` on presigned URLs resolved from the original/variant
row. Path-style addressing via the `:endpoint` option 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 test` whenever the `garage` binary is
available (the dev shell provides it), excluded otherwise.
- Direct-upload groundwork: `Attached.StorageBackends.direct_upload_url/2`
returns a URL (plus required headers) for uploading a key straight from the
browser via HTTP PUT. S3 presigns the PUT with `content-md5`,
`content-type`, and `content-length` pinned in the signature; Disk serves a
purpose-bound token handled by a new `PUT /originals/:token` route in
`Attached.Web.Plug` (with optional `:max_upload_size` and Content-MD5
verification). `Attached.Web.Signer` tokens 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 than `config :attached, :orphan_grace_period` (default 48
hours, `0` disables), so originals created ahead of their attachment —
e.g. direct uploads in flight — survive the sweep. `list_orphans/...` and
`count_orphans/...` still report all current orphans regardless of age.
### Fixed
- `Attached.Variants.process/3` is now actually idempotent under concurrency:
two simultaneous callers for the same uncached variant no longer crash the
loser with `Ecto.ConstraintError` — it returns the winner's cached row.
- `resize_and_pad` in the Vix transformer now pads to the exact target
dimensions with a transparent background (it behaved like `resize_to_fit`),
matching the ImageMagick backend.
- `path_for/1` now has an explicit `nil` clause, resolving an Elixir 1.20 type
warning when tests pass `nil` to verify the security guard.
- Logger level set to `:warning` in test env, suppressing debug query output.
- All DB-touching tests migrated from `ExUnit.Case` + manual sandbox checkout to
`Attached.DataCase`, eliminating sandbox ownership races.
- `ImageMagick.metadata/1` now returns `%{}` early for nonexistent paths via
`File.exists?/1`, avoiding a noisy `identify` stderr error in tests.
- ImageMagick metadata tests use a JPEG fixture with an embedded EXIF orientation
tag, eliminating the `unknown image property` stderr warning.
- `VixTest` now uses `Code.ensure_loaded?(Vix)` instead of `Code.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.exs` was missing from the published Hex package, preventing
`import_deps: [:attached]` from working for consumers.
## [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).