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