Skip to main content

CHANGELOG.md

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