Skip to content

ash-project/ash_storage

AshStorage

CI Hex version

An Ash extension for file storage and attachments.

Installation

Add ash_storage to your list of dependencies in mix.exs:

def deps do
  [
    {:ash_storage, "~> 0.1.0"}
  ]
end

Setup

AshStorage requires three resources: a blob resource to store file metadata, an attachment resource to link blobs to records, and one or more host resources that declare attachments.

1. Blob resource

defmodule MyApp.StorageBlob do
  use Ash.Resource,
    domain: MyApp.Domain,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshStorage.BlobResource]

  postgres do
    table "storage_blobs"
    repo MyApp.Repo
  end

  blob do
  end

  attributes do
    uuid_primary_key :id
  end
end

2. Attachment resource

For a single-parent use case with proper foreign keys:

defmodule MyApp.StorageAttachment do
  use Ash.Resource,
    domain: MyApp.Domain,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshStorage.AttachmentResource]

  postgres do
    table "storage_attachments"
    repo MyApp.Repo
  end

  attachment do
    blob_resource MyApp.StorageBlob
    belongs_to_resource :post, MyApp.Post
  end

  attributes do
    uuid_primary_key :id
  end
end

For attachments shared across multiple resource types, declare multiple belongs_to_resource entries (foreign keys will be nullable):

attachment do
  blob_resource MyApp.StorageBlob
  belongs_to_resource :post, MyApp.Post
  belongs_to_resource :comment, MyApp.Comment
end

For fully polymorphic attachments (using record_type/record_id string columns instead of foreign keys), omit belongs_to_resource entirely:

attachment do
  blob_resource MyApp.StorageBlob
end

3. Host resource

defmodule MyApp.Post do
  use Ash.Resource,
    domain: MyApp.Domain,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshStorage],
    otp_app: :my_app

  storage do
    service {AshStorage.Service.Disk, root: "priv/storage", base_url: "/storage"}
    blob_resource MyApp.StorageBlob
    attachment_resource MyApp.StorageAttachment

    has_one_attached :cover_image
    has_many_attached :documents
  end

  # ...
end

This automatically adds:

  • has_one :cover_image / has_many :documents relationships to load attachments
  • A cover_image_url calculation for each has_one_attached

Usage

Attaching files

{:ok, %{blob: blob}} =
  AshStorage.Operations.attach(post, :cover_image, file_data,
    filename: "photo.jpg",
    content_type: "image/jpeg"
  )

For has_one_attached, attaching replaces any existing attachment (the old file is purged). For has_many_attached, each attach appends.

Loading attachments

post = Ash.load!(post, :cover_image)
post.cover_image.blob.filename
#=> "photo.jpg"

post = Ash.load!(post, :cover_image_url)
post.cover_image_url
#=> "/storage/a81bf21e2442..."

post = Ash.load!(post, documents: :blob)
Enum.map(post.documents, & &1.blob.filename)
#=> ["report.pdf", "notes.txt"]

Detaching and purging

# Detach (remove link, keep file)
AshStorage.Operations.detach(post, :cover_image)

# Purge (remove link, blob record, and file)
AshStorage.Operations.purge(post, :cover_image)

# For has_many_attached, specify which blob
AshStorage.Operations.detach(post, :documents, blob_id: blob.id)
AshStorage.Operations.purge(post, :documents, blob_id: blob.id)

# Purge all documents
AshStorage.Operations.purge(post, :documents, all: true)

Dependent destroy

Control what happens to attachments when a record is destroyed:

storage do
  has_one_attached :cover_image                    # default: dependent: :purge
  has_many_attached :documents, dependent: :detach # keep files, remove links
  has_many_attached :logs, dependent: false         # do nothing
end

File deletion happens outside the database transaction, so a failed file delete won't roll back the record destroy.

Soft destroy actions (where action.soft? is true) skip dependent attachment handling entirely.

Configuring the storage service

Per-resource (DSL)

storage do
  service {AshStorage.Service.Disk, root: "priv/storage", base_url: "/storage"}
end

Per-attachment (DSL)

storage do
  has_one_attached :avatar, service: {AshStorage.Service.S3, bucket: "avatars"}
end

Per-environment (application config)

Override the service at runtime using application config. This requires otp_app on the resource:

# The resource
defmodule MyApp.Post do
  use Ash.Resource,
    extensions: [AshStorage],
    otp_app: :my_app
  # ...
end

Override the resource-level service:

# config/test.exs
config :my_app, MyApp.Post,
  storage: [service: {AshStorage.Service.Test, []}]

Override a specific attachment's service:

# config/prod.exs
config :my_app, MyApp.Post,
  storage: [
    has_one_attached: [
      avatar: [service: {AshStorage.Service.S3, bucket: "prod-avatars"}]
    ]
  ]

Resolution order (first match wins):

  1. Per-attachment app config
  2. Per-attachment DSL service option
  3. Resource-level app config
  4. Resource-level DSL service option

Switching to a test service

AshStorage.Service.Test is an in-memory service for tests. Set it up in your test config:

# config/test.exs
config :my_app, MyApp.Post,
  storage: [service: {AshStorage.Service.Test, []}]

Then in your test helper or setup:

# test/test_helper.exs
AshStorage.Service.Test.start()

# In each test
setup do
  AshStorage.Service.Test.reset!()
  :ok
end

Storage services

AshStorage ships with:

  • AshStorage.Service.Disk — Local filesystem storage
  • AshStorage.Service.Test — In-memory storage for tests
  • AshStorage.Service.S3 — S3-compatible storage (requires req_s3)

Implement the AshStorage.Service behaviour to add custom backends.

Roadmap

  • Analyzers ✅ — Pluggable metadata extraction (image dimensions, video duration, audio bitrate) stored in blob analyzers map. Runs synchronously during attach from local IO by default. With AshOban: optionally enqueue via analyze: :oban. Supports write_attributes to write results back to parent record attributes. Custom analyzers implement the AshStorage.Analyzer behaviour.
  • Variants ✅ — File transformations: image resizing/conversion, PDF-to-thumbnail, video thumbnails, and any custom transform. Subsumes the previewer concept — a PDF thumbnail is just a variant. Three generation modes: :on_demand (default, generated inline on first URL request), :eager (during attach), :oban (background job via AshOban). Variant blobs are self-referential on the blob resource with digest-based cache invalidation. Named variants declared in DSL via variant :name, {Module, opts}. Custom transformers implement AshStorage.Variant behaviour.
  • Per-variant oban jobs — Currently all pending variants for a blob run in a single oban job. Refactor so each variant gets its own job lifecycle, enabling parallel generation and independent retries.
  • Checksum verification — Integrity checking via checksums on upload
  • Redirect handler — A plug that redirects to the storage service URL instead of proxying
  • Mirroring — Mirror service that replicates uploads across multiple backends for redundancy
  • Orphan cleanup — Periodic cleanup of blobs without files or files without blobs. With AshOban: scheduled job. Without: manual invocation via AshStorage.Operations.cleanup_orphans/1.

Future services

  • GCS — Google Cloud Storage backend
  • Azure — Azure Blob Storage backend

Library options under consideration

These are the Elixir libraries we're evaluating for each roadmap feature. All would be optional dependencies.

Image processing (for variants)

Library Approach Notes
image + vix libvips NIFs Recommended. 2-3x faster than ImageMagick, ~5x less memory. Ships pre-built binaries for macOS/Linux. Supports JPEG, PNG, WebP, TIFF, SVG, HEIF, GIF, AVIF.
mogrify ImageMagick shell-out Legacy option. Well-known but ImageMagick has a much larger CVE surface than libvips.

Image metadata extraction (for analyzers)

Library Approach Notes
ex_image_info Pure Elixir Recommended for lightweight use. Zero deps. Gets dimensions + detected MIME from binary data. Supports JPEG, PNG, GIF, BMP, TIFF, WebP, PSD, SVG, ICO.
exexif Pure Elixir EXIF/TIFF metadata from JPEGs (camera info, GPS, exposure).
image libvips Also extracts dimensions and metadata. Good if already using it for variants.

Video/audio metadata and thumbnails (for analyzers + variants)

Library Approach Notes
ffmpex FFmpeg shell-out Recommended. Wraps ffprobe for metadata (duration, bitrate, codecs, dimensions) and ffmpeg for thumbnail extraction. Stable, well-understood.
xav FFmpeg NIFs NIF-based, no shell-out. Part of elixir-webrtc org, actively maintained. Tighter integration but heavier dependency.
thumbnex ImageMagick + FFmpeg Simple API for thumbnails from images, videos, and PDFs. Uses convert for PDFs, ffmpeg for videos.

PDF thumbnails (for variants)

Library Approach Notes
image / vix libvips + poppler Can render PDF pages to images if libvips is compiled with poppler/PDFium support. Pre-built binaries may or may not include poppler.
thumbnex ImageMagick shell-out Uses convert to render first page. Requires ImageMagick with Ghostscript.

File type detection / content sniffing (for analyzers)

Library Approach Notes
gen_magic libmagic NIF Most accurate — uses the same library behind the Unix file command. Supervised process with pooling. Requires libmagic system dep.
ex_marcel Pure Elixir Port of Rails' Marcel gem (used by ActiveStorage). Uses Apache Tika signature data. No system deps.
magic_number Pure Elixir Lightweight magic number matching. Older, less actively maintained.

Documentation

About

Attachment and file management for Ash Framework

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Languages