MartenStorages::Service
Module-level helpers for attaching files to records via a host-defined
polymorphic Attachment model. Replaces the Active-Storage-style
has_one_attached / has_many_attached macros.
The host owns the concrete Attachment model class (because Marten's
polymorphic to: list is compile-time fixed); the service takes it
as a model: keyword argument so the same shard can work against any
app's concrete table.
Required fields on the host's Attachment model:
field :id, :big_int, primary_key: true, auto: true field :record, :polymorphic, to: [...host targets...] field :name, :string field :file, :file field :variant_of, :many_to_one, to: <self>, blank: true, null: true field :variation_kind, :string, blank: true, null: true field :content_type, :string, blank: true, null: true field :byte_size, :big_int, blank: true, null: true
Basic upload:
MartenStorages::Service.attach( model: ::Books::Attachment, record: book, name: "cover", uploaded_file: uploaded, )
With variants (pre-computed at upload time via crystal-vips):
MartenStorages::Service.attach( model: ::Books::Attachment, record: picture, name: "image", uploaded_file: uploaded, variants: {"large" => {max_dimension: 1500}}, )
Perf note (review §5): on a filesystem media backend each variant
currently lands on disk four times (original upload tempfile,
temp_path source copy here, libvips variant_path output,
storage backend write of the final variant). The redundant
temp_path copy can be eliminated for filesystem backends by
reading the original's path directly when the storage backend
responds_to?(:path); for cloud backends the copy stays
necessary. Deferred — fine for Writebook-scale uploads (~MB), and
the cloud-backend story is a separate piece of work.
Fiber-safety note (review §6): source_io = original_file.open
returns a fresh File for the filesystem backend, which is
non-blocking enough for the local case. If/when a cloud backend
whose open streams an HTTP body lands, ::IO.copy(source_io, ...)
below will block the fiber for the duration of the download — at
that point this method should run on a worker fiber or use
streaming/multipart copies.
Instance methods
Attach a file to a record. Returns the saved Attachment row.
If variants are provided, additionally creates one Attachment row
per variant kind, each pointing at the original via variant_of.
content_type: is the MIME type to persist on the original row
(typically pulled from the HTTP::FormData::Part's Content-Type
header by the host handler). Defaults to nil for back-compat;
variants always compute their own content type from the configured
output format.
The whole attach (original + every variant) is wrapped in a single DB transaction so a mid-loop failure rolls back the original row too (review §7). Variant files already written to media storage before the failing variant are not rolled back by the DB transaction — that's a known limitation tracked separately.
Back-compat overload: accept the legacy NamedTuple-shaped variants
hash ({"thumbnail" => {max_dimension: 200}}) and normalize each
spec to a VariantSpec Struct (review §19). Pre-existing call
sites that use NamedTuple literals keep working unchanged.
Back-compat overload: accept the NamedTuple shape with an explicit
format: field ({"thumbnail" => {max_dimension: 200, format: "webp"}})
so call sites that need per-variant format overrides can stay on
the literal NamedTuple form without dropping to the Struct API.
Resolve every original attachment for a record + name (oldest first), ignoring variant rows.
Resolve a single attachment for a record + name (e.g. Book cover). Returns the most recently created original (non-variant).
Ordering uses the symbol-form :created_at (with .reverse)
everywhere in this module for consistency (review §16).