{"id":50486264,"url":"https://github.com/zebbra/ash_storage_pglo","last_synced_at":"2026-06-01T22:31:06.564Z","repository":{"id":353340410,"uuid":"1211909671","full_name":"zebbra/ash_storage_pglo","owner":"zebbra","description":"AshStorage service backend that stores attachments as PostgreSQL large objects.","archived":false,"fork":false,"pushed_at":"2026-04-23T12:36:26.000Z","size":43,"stargazers_count":0,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-04-23T14:32:36.121Z","etag":null,"topics":["ash","ash-storage","postgres"],"latest_commit_sha":null,"homepage":"https://hexdocs.pm/ash_storage_pglo/","language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/zebbra.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-15T21:51:25.000Z","updated_at":"2026-04-15T21:58:25.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/zebbra/ash_storage_pglo","commit_stats":null,"previous_names":["zebbra/ash_storage_pglo"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/zebbra/ash_storage_pglo","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zebbra%2Fash_storage_pglo","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zebbra%2Fash_storage_pglo/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zebbra%2Fash_storage_pglo/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zebbra%2Fash_storage_pglo/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/zebbra","download_url":"https://codeload.github.com/zebbra/ash_storage_pglo/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/zebbra%2Fash_storage_pglo/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33797126,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-01T02:00:06.963Z","response_time":115,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ash","ash-storage","postgres"],"created_at":"2026-06-01T22:31:05.691Z","updated_at":"2026-06-01T22:31:06.552Z","avatar_url":"https://github.com/zebbra.png","language":"Elixir","funding_links":[],"categories":[],"sub_categories":[],"readme":"[![CI](https://github.com/zebbra/ash_storage_pglo/actions/workflows/ci.yml/badge.svg)](https://github.com/zebbra/ash_storage_pglo/actions/workflows/ci.yml)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)\n[![Hex version badge](https://img.shields.io/hexpm/v/ash_storage_pglo.svg)](https://hex.pm/packages/ash_storage_pglo)\n[![Hexdocs badge](https://img.shields.io/badge/docs-hexdocs-purple)](https://hexdocs.pm/ash_storage_pglo)\n\n# AshStoragePGLO\n\nAn [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).\n\nKeep 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).\n\n## When to use this\n\nPostgreSQL large objects are a good fit when you want:\n\n- **Multiple nodes.** Data can be accessed from multiple nodes without additional services.\n- **Multiple environments.** Use the same service for `:dev`, `:prod`, and `:test` environments.\n- **One storage target.** Backups, snapshots, and replication cover your uploads automatically.\n- **Transactional writes.** Storing data as PG large object and creating the blob resource run in the same transation — if either fails, both roll back.\n- **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.\n- **Streaming.** Supports streaming blobs of up to 4TB for reads and writes.\n\nIt'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.\n\n## Installation\n\nAshStoragePGLO is not yet published to Hex. For now, depend on it from source:\n\n```elixir\ndef deps do\n  [\n    {:ash_storage, \"~\u003e 0.1\"},\n    {:ash_storage_pglo, github: \"zebbra/ash_storage_pglo\"}\n  ]\nend\n```\n\nYou also need the `lo` extension enabled on your database:\n\n```elixir\n# lib/my_app/repo.ex\ndef installed_extensions do\n  [\"ash-functions\", \"lo\"]\nend\n```\n\nRun `mix ash.codegen install_lo_extension` to generate the migration that enables it.\n\n## Setup\n\nAshStoragePGLO 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.\n\n### 1. Mapping resource\n\n```elixir\ndefmodule MyApp.StorageLO do\n  use Ash.Resource,\n    domain: MyApp.Domain,\n    data_layer: AshPostgres.DataLayer,\n    extensions: [AshStoragePGLO.Resource]\n\n  postgres do\n    table \"storage_los\"\n    repo MyApp.Repo\n  end\n\n  lo do\n  end\nend\n```\n\nThe `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.\n\nRegister `StorageLO` in your domain:\n\n```elixir\ndefmodule MyApp.Domain do\n  use Ash.Domain\n\n  resources do\n    resource MyApp.StorageLO\n    # ... your other resources\n  end\nend\n```\n\nRun `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.\n\n### 2. AshStorage blob and attachment resources\n\nThese are the usual AshStorage resources — AshStoragePGLO doesn't replace them. A minimal pair:\n\n```elixir\ndefmodule MyApp.StorageBlob do\n  use Ash.Resource,\n    domain: MyApp.Domain,\n    data_layer: AshPostgres.DataLayer,\n    extensions: [AshStorage.BlobResource]\n\n  postgres do\n    table \"storage_blobs\"\n    repo MyApp.Repo\n  end\n\n  blob do\n  end\n\n  attributes do\n    uuid_primary_key :id\n  end\nend\n```\n\n```elixir\ndefmodule MyApp.StorageAttachment do\n  use Ash.Resource,\n    domain: MyApp.Domain,\n    data_layer: AshPostgres.DataLayer,\n    extensions: [AshStorage.AttachmentResource]\n\n  postgres do\n    table \"storage_attachments\"\n    repo MyApp.Repo\n  end\n\n  attachment do\n    blob_resource MyApp.StorageBlob\n    belongs_to_resource :post, MyApp.Post\n  end\n\n  attributes do\n    uuid_primary_key :id\n  end\nend\n```\n\n### 3. Host resource\n\nWire `AshStoragePGLO.Service` into any resource that declares attachments:\n\n```elixir\ndefmodule MyApp.Post do\n  use Ash.Resource,\n    domain: MyApp.Domain,\n    data_layer: AshPostgres.DataLayer,\n    extensions: [AshStorage]\n\n  storage do\n    service {AshStoragePGLO.Service,\n             lo_resource: MyApp.StorageLO,\n             base_url: \"/storage\"}\n\n    blob_resource MyApp.StorageBlob\n    attachment_resource MyApp.StorageAttachment\n\n    has_one_attached :cover_image\n  end\n\n  # ...\nend\n```\n\n### 4. Serving downloads\n\nMount `AshStorage.Plug.Proxy` in your router. It calls `AshStoragePGLO.Service.download/2` and streams the result:\n\n```elixir\nscope \"/\", MyAppWeb do\n  forward \"/storage\", AshStorage.Plug.Proxy,\n    service: {AshStoragePGLO.Service, lo_resource: MyApp.StorageLO}\nend\n```\n\nThe `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.\n\n**Limitations:** \n\n- All service opts (incl. `base_url`) is stored on the blob database record. \n- `AshStorage.Plug.Proxy` currently does not support caching.\n\n## Usage\n\nWith 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:\n\n```elixir\n{:ok, post} = Ash.create(MyApp.Post, %{title: \"Hello, world!\"})\n\n{:ok, _} =\n  AshStorage.Operations.attach(post, :cover_image, file_bytes,\n    filename: \"world.jpg\",\n    content_type: \"image/jpeg\"\n  )\n\npost = Ash.load!(post, :cover_image_url)\npost.cover_image_url\n#=\u003e \"/storage/01h9z8qtabc...\"\n```\n\nDestroying 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.\n\n## Service options\n\nThe `{AshStoragePGLO.Service, opts}` tuple takes:\n\n- `:lo_resource` — **required.** The `AshStoragePGLO.Resource` mapping resource (e.g. `MyApp.StorageLO`).\n- `:base_url` — **required for `url/2`.** The path where `AshStorage.Plug.Proxy` is mounted.\n\n**Note:** All service options are stored on the blob resource by `ash_storage`. Existing blobs must be updated if options are changed!\n\n## Limitations\n\n- **No direct uploads.** `direct_upload/2` is not implemented — large objects need an open DB connection, so there's no meaningful presigned flow.\n- **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.\n- **No caching.** `AshStorage.Plug.Proxy` does not support caching, yet. \n\n## Documentation\n\n- [`pg_large_objects`](https://hex.pm/packages/pg_large_objects) — the low-level library this extension wraps\n- [`ash_storage`](https://hexdocs.pm/ash_storage) — the extension this plugs into\n- [PostgreSQL large objects](https://www.postgresql.org/docs/current/largeobjects.html) — upstream docs\n\n## Authors\n\nThis library is created by 🦓 [zebbra](https://zebbra.ch). Need Elixir expertise made in 🇨🇭 Switzerland? Feel free to [reach out](https://zebbra.ch/contact).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzebbra%2Fash_storage_pglo","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fzebbra%2Fash_storage_pglo","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fzebbra%2Fash_storage_pglo/lists"}