https://github.com/zebbra/ash_storage_pglo
AshStorage service backend that stores attachments as PostgreSQL large objects.
https://github.com/zebbra/ash_storage_pglo
ash ash-storage postgres
Last synced: 17 days ago
JSON representation
AshStorage service backend that stores attachments as PostgreSQL large objects.
- Host: GitHub
- URL: https://github.com/zebbra/ash_storage_pglo
- Owner: zebbra
- License: mit
- Created: 2026-04-15T21:51:25.000Z (2 months ago)
- Default Branch: main
- Last Pushed: 2026-04-23T12:36:26.000Z (about 2 months ago)
- Last Synced: 2026-04-23T14:32:36.121Z (about 2 months ago)
- Topics: ash, ash-storage, postgres
- Language: Elixir
- Homepage: https://hexdocs.pm/ash_storage_pglo/
- Size: 42 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
[](https://github.com/zebbra/ash_storage_pglo/actions/workflows/ci.yml)
[](https://opensource.org/licenses/MIT)
[](https://hex.pm/packages/ash_storage_pglo)
[](https://hexdocs.pm/ash_storage_pglo)
# AshStoragePGLO
An [AshStorage](https://hexdocs.pm/ash_storage) service backend that stores attachment bytes as PostgreSQL [large objects](https://www.postgresql.org/docs/current/largeobjects.html) via [`pg_large_objects`](https://hex.pm/packages/pg_large_objects).
Keep your uploads inside Postgres — no S3, no disk, no extra infrastructure. Backups, replication, and transactional deletes all come for free from the database you already run. Works across multiple nodes and any env (dev, test, prod).
## When to use this
PostgreSQL large objects are a good fit when you want:
- **Multiple nodes.** Data can be accessed from multiple nodes without additional services.
- **Multiple environments.** Use the same service for `:dev`, `:prod`, and `:test` environments.
- **One storage target.** Backups, snapshots, and replication cover your uploads automatically.
- **Transactional writes.** Storing data as PG large object and creating the blob resource run in the same transation — if either fails, both roll back.
- **Automatic cleanup.** The `lo_manage` trigger this library installs ties each large object's lifetime to the row that references it. Delete the row, the bytes go too — no orphans.
- **Streaming.** Supports streaming blobs of up to 4TB for reads and writes.
It's *not* a good fit if you need CDN edge caching, cross-region reads, or files larger than what a single Postgres instance can comfortably hold. See the `pg_large_objects` [considerations doc](https://github.com/frerich/pg_large_objects/blob/main/CONSIDERATIONS.md) for the trade-offs.
## Installation
AshStoragePGLO is not yet published to Hex. For now, depend on it from source:
```elixir
def deps do
[
{:ash_storage, "~> 0.1"},
{:ash_storage_pglo, github: "zebbra/ash_storage_pglo"}
]
end
```
You also need the `lo` extension enabled on your database:
```elixir
# lib/my_app/repo.ex
def installed_extensions do
["ash-functions", "lo"]
end
```
Run `mix ash.codegen install_lo_extension` to generate the migration that enables it.
## Setup
AshStoragePGLO needs one resource of its own — the mapping table that translates between AshStorage's string `key`s and Postgres's numeric `oid`s (reference to stored PG LO) — plus the usual AshStorage blob and attachment resources.
### 1. Mapping resource
```elixir
defmodule MyApp.StorageLO do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer,
extensions: [AshStoragePGLO.Resource]
postgres do
table "storage_los"
repo MyApp.Repo
end
lo do
end
end
```
The `AshStoragePGLO.Resource` extension adds the `:key` and `:oid` attributes, the actions the service dispatches through (`:import`, `:download`, `:destroy`), and an `lo_manage BEFORE UPDATE OR DELETE` trigger as a `custom_statement` — so whenever a mapping row is deleted, its underlying large object is unlinked in the same transaction.
Register `StorageLO` in your domain:
```elixir
defmodule MyApp.Domain do
use Ash.Domain
resources do
resource MyApp.StorageLO
# ... your other resources
end
end
```
Run `mix ash.codegen create_storage_lo_table`. The generated migration will create the table with the correct `oid` column type and the `lo_manage` trigger.
### 2. AshStorage blob and attachment resources
These are the usual AshStorage resources — AshStoragePGLO doesn't replace them. A minimal pair:
```elixir
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
```
```elixir
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
```
### 3. Host resource
Wire `AshStoragePGLO.Service` into any resource that declares attachments:
```elixir
defmodule MyApp.Post do
use Ash.Resource,
domain: MyApp.Domain,
data_layer: AshPostgres.DataLayer,
extensions: [AshStorage]
storage do
service {AshStoragePGLO.Service,
lo_resource: MyApp.StorageLO,
base_url: "/storage"}
blob_resource MyApp.StorageBlob
attachment_resource MyApp.StorageAttachment
has_one_attached :cover_image
end
# ...
end
```
### 4. Serving downloads
Mount `AshStorage.Plug.Proxy` in your router. It calls `AshStoragePGLO.Service.download/2` and streams the result:
```elixir
scope "/", MyAppWeb do
forward "/storage", AshStorage.Plug.Proxy,
service: {AshStoragePGLO.Service, lo_resource: MyApp.StorageLO}
end
```
The `base_url` you set on the service must match the path you forward at — the service's `url/2` produces `"#{base_url}/#{key}"`, and the Proxy plug dispatches on the remainder.
**Limitations:**
- All service opts (incl. `base_url`) is stored on the blob database record.
- `AshStorage.Plug.Proxy` currently does not support caching.
## Usage
With the setup above, uploads and downloads go through AshStorage's normal API. Nothing about the host resource's code looks different from any other AshStorage backend:
```elixir
{:ok, post} = Ash.create(MyApp.Post, %{title: "Hello, world!"})
{:ok, _} =
AshStorage.Operations.attach(post, :cover_image, file_bytes,
filename: "world.jpg",
content_type: "image/jpeg"
)
post = Ash.load!(post, :cover_image_url)
post.cover_image_url
#=> "/storage/01h9z8qtabc..."
```
Destroying the photo cascades through AshStorage's dependent-attachment handler, which calls `AshStoragePGLO.Service.delete/2`. That in turn runs a bulk destroy on the mapping row — and the `lo_manage` trigger cleans up the underlying large object in the same transaction. No orphaned bytes.
## Service options
The `{AshStoragePGLO.Service, opts}` tuple takes:
- `:lo_resource` — **required.** The `AshStoragePGLO.Resource` mapping resource (e.g. `MyApp.StorageLO`).
- `:base_url` — **required for `url/2`.** The path where `AshStorage.Plug.Proxy` is mounted.
**Note:** All service options are stored on the blob resource by `ash_storage`. Existing blobs must be updated if options are changed!
## Limitations
- **No direct uploads.** `direct_upload/2` is not implemented — large objects need an open DB connection, so there's no meaningful presigned flow.
- **No streaming.** `AshStorage.Plug.Proxy` reads the full binary into memory before sending the response. Fine for photos, not for multi-GB files. An (upstream?) streaming plug is a plausible future addition.
- **No caching.** `AshStorage.Plug.Proxy` does not support caching, yet.
## Documentation
- [`pg_large_objects`](https://hex.pm/packages/pg_large_objects) — the low-level library this extension wraps
- [`ash_storage`](https://hexdocs.pm/ash_storage) — the extension this plugs into
- [PostgreSQL large objects](https://www.postgresql.org/docs/current/largeobjects.html) — upstream docs
## Authors
This library is created by 🦓 [zebbra](https://zebbra.ch). Need Elixir expertise made in 🇨🇭 Switzerland? Feel free to [reach out](https://zebbra.ch/contact).