module

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(model : T.class, record : Marten::DB::Model, name : String, uploaded_file : Marten::HTTP::UploadedFile, variants : Hash(String, VariantSpec) = ({} of ::String => VariantSpec), content_type : String | Nil = nil) : T forall T

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.

Source
attach(model : T.class, record : Marten::DB::Model, name : String, uploaded_file : Marten::HTTP::UploadedFile, variants : Hash(String, NamedTuple(max_dimension: Int32)), content_type : String | Nil = nil) : T forall T

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.

Source
attach(model : T.class, record : Marten::DB::Model, name : String, uploaded_file : Marten::HTTP::UploadedFile, variants : Hash(String, NamedTuple(max_dimension: Int32, format: String)), content_type : String | Nil = nil) : T forall T

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.

Source
find_many(model : T.class, record : Marten::DB::Model, name : String) : Array(T) forall T

Resolve every original attachment for a record + name (oldest first), ignoring variant rows.

Source
find_one(model : T.class, record : Marten::DB::Model, name : String) : T | Nil forall T

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

Source
variant_of(model : T.class, original : T, kind : String) : T | Nil forall T

Look up a variant row for a given original Attachment + variation_kind.

Source

Nested types