Rindle (Rindle v0.1.10)

Copy Markdown View Source

Phoenix/Ecto-native media lifecycle library.

Rindle manages the full post-upload lifecycle: upload sessions, staged object verification, asset modeling, attachment associations, variant/derivative generation, background processing, secure delivery, observability, and day-2 operations.

Owner/account erasure contract

The recommended v1.10 owner/account erasure facade is Rindle.preview_owner_erasure/2 for dry-run planning and Rindle.erase_owner/2 for the execute lane. Preview and execute share the same owner_erasure_report() vocabulary so adopters can audit what will be detached now, what purge work is enqueued later, and which shared assets are intentionally retained.

For multi-owner orchestration, Rindle.preview_batch_owner_erasure/2 and Rindle.erase_batch_owner_erasure/2 are the supported batch preview and execute entrypoints. Batch reports use owner_erasure_batch_report() with per-owner nested owner_erasure_report() entries.

The stable report buckets are attachments_to_detach, assets_to_purge, and retained_shared_assets.

Shared assets are retained whenever a surviving attachment remains. Execute semantics stay honest: detach work happens transactionally first, then purge-enqueued async cleanup handles newly orphaned assets later. This facade contract does not promise inline storage deletion, admin UI, scheduler/cron erasure jobs, or force-delete behavior for assets with surviving attachments.

detach/3 remains the slot-scoped attachment API, and mix rindle.cleanup_orphans remains the maintenance-only upload-residue cleanup lane rather than the owner/account erasure surface.

Summary

Types

Detail map when batch processing stops after a per-owner failure.

Detail map for batch size limit violations.

Per-owner entry in a batch erasure report.

Aggregate batch erasure report with per-owner nested reports.

Count-plus-list reporting bucket used by owner_erasure_report().

Stable public owner/account erasure report vocabulary shared by Rindle.preview_owner_erasure/2 and Rindle.erase_owner/2.

Owner identity reference extracted from owner structs. Matches the tuple Rindle.Internal.OwnerErasure derives internally.

Tagged storage result shape: {:ok, result} | {:error, reason}

Functions

Attaches a MediaAsset to an owner at a specific slot.

Same as attach/4 but raises Rindle.Error on failure or re-raises the original exception for storage adapter exceptions. Database constraint failures (e.g., foreign-key violations) surface as Rindle.Error with the underlying changeset as the reason.

Fetches the most recent MediaAttachment for an (owner, slot) pair.

Cancels active variant processing for an asset.

Cancels a broker-owned resumable upload session.

Completes a multipart upload through the broker and reuses upload verification.

Deletes an object through the profile-specific storage adapter.

Detaches any MediaAsset from an owner at a specific slot and triggers a purge.

Same as detach/3 but raises Rindle.Error on failure.

Downloads an object through the profile-specific storage adapter.

Erases multiple owners' Rindle-managed attachment rows and enqueues orphan-only purge work.

Erases an owner's Rindle-managed attachment rows and enqueues orphan-only purge work.

Checks for object existence through the profile-specific storage adapter.

Initiates a multipart direct upload session through the broker.

Initiates a resumable upload session through the broker.

Initiates a tus upload resource through the broker and returns the signed upload URL needed by browser tus clients.

Initiates a direct upload session through the broker.

Generates a presigned PUT payload through the profile-specific storage adapter.

Plans batch owner/account erasure without changing DB or storage state.

Plans owner/account erasure without changing DB or storage state.

Lists MediaVariant rows in the "ready" state for a given asset.

Reruns probe detection for an asset and persists only probe-derived fields.

Requeues failed or cancelled variants for a single asset.

Polls the broker-owned resumable upload session without changing completion trust.

Returns a bounded runtime diagnostics report for operators.

Signs a single multipart upload part through the broker.

Resolves the storage adapter module for a given profile.

Stores an object through the profile-specific storage adapter.

Executes variant storage and logs failures with required context metadata.

Uploads a file directly through the server (proxied upload).

Same as upload/3 but raises Rindle.Error on failure, Ecto.InvalidChangesetError for changeset failures, or re-raises the original exception for storage adapter exceptions.

Generates a delivery URL through the profile-specific storage adapter.

Same as url/3 but raises Rindle.Error on failure or re-raises the original exception for storage adapter exceptions.

Generates a delivery URL for a variant, falling back when needed.

Same as variant_url/4 but raises Rindle.Error on failure or re-raises the original exception for storage adapter exceptions.

Verifies a direct upload completion through the broker.

Verifies a direct upload completion through the broker.

Returns the current version of Rindle.

Types

batch_owner_erasure_result()

@type batch_owner_erasure_result() ::
  {:ok, owner_erasure_batch_report()}
  | {:error, :empty_batch}
  | {:error, {:batch_too_large, batch_too_large_detail()}}
  | {:error, term()}

batch_owner_failed_detail()

@type batch_owner_failed_detail() :: %{
  owner: owner_ref(),
  reason: term(),
  partial_report: owner_erasure_batch_report()
}

Detail map when batch processing stops after a per-owner failure.

batch_too_large_detail()

@type batch_too_large_detail() :: %{requested: non_neg_integer(), max: pos_integer()}

Detail map for batch size limit violations.

owner_erasure_batch_entry()

@type owner_erasure_batch_entry() :: %{
  owner: owner_ref(),
  report: owner_erasure_report()
}

Per-owner entry in a batch erasure report.

owner_erasure_batch_report()

@type owner_erasure_batch_report() :: %{
  mode: :preview | :execute,
  attachments_to_detach: owner_erasure_bucket(),
  assets_to_purge: owner_erasure_bucket(),
  retained_shared_assets: owner_erasure_bucket(),
  owners: [owner_erasure_batch_entry()]
}

Aggregate batch erasure report with per-owner nested reports.

owner_erasure_bucket()

@type owner_erasure_bucket() :: %{count: non_neg_integer(), entries: [map()]}

Count-plus-list reporting bucket used by owner_erasure_report().

owner_erasure_report()

@type owner_erasure_report() :: %{
  mode: :preview | :execute,
  attachments_to_detach: owner_erasure_bucket(),
  assets_to_purge: owner_erasure_bucket(),
  retained_shared_assets: owner_erasure_bucket(),
  purge_enqueued: non_neg_integer(),
  purge_already_queued: non_neg_integer()
}

Stable public owner/account erasure report vocabulary shared by Rindle.preview_owner_erasure/2 and Rindle.erase_owner/2.

attachments_to_detach reports the owner attachment rows targeted by the operation, assets_to_purge reports newly orphaned assets whose purge work is enqueued, and retained_shared_assets reports assets retained because a surviving attachment still exists.

owner_ref()

@type owner_ref() :: {owner_type :: String.t(), owner_id :: Ecto.UUID.t()}

Owner identity reference extracted from owner structs. Matches the tuple Rindle.Internal.OwnerErasure derives internally.

storage_result()

@type storage_result() :: {:ok, term()} | {:error, term()}

Tagged storage result shape: {:ok, result} | {:error, reason}

Functions

attach(asset_or_id, owner, slot, opts \\ [])

@spec attach(Rindle.Domain.MediaAsset.t() | binary(), struct(), String.t(), keyword()) ::
  {:ok, Rindle.Domain.MediaAttachment.t()} | {:error, term()}

Attaches a MediaAsset to an owner at a specific slot.

If an attachment already exists in that slot, it is replaced and the old asset is purged asynchronously via PurgeStorage.

Examples

# Requires a configured Rindle repo + an existing MediaAsset and owner record.
iex> {:ok, attachment} = Rindle.attach(asset_id, %MyApp.User{id: user_id}, "avatar")
iex> attachment.slot
"avatar"

attach!(asset_or_id, owner, slot, opts \\ [])

Same as attach/4 but raises Rindle.Error on failure or re-raises the original exception for storage adapter exceptions. Database constraint failures (e.g., foreign-key violations) surface as Rindle.Error with the underlying changeset as the reason.

attachment_for(owner, slot, opts \\ [])

@spec attachment_for(struct(), String.t(), keyword()) ::
  Rindle.Domain.MediaAttachment.t() | nil

Fetches the most recent MediaAttachment for an (owner, slot) pair.

Returns the attachment row with :asset preloaded by default, or nil when no attachment exists at the slot. Pass preload: <list> to override the default preload (the override replaces [:asset] rather than merging — pass preload: [asset: :variants] to extend, preload: [] to disable preloading entirely).

When multiple attachment rows exist for the same (owner, slot) (possible because the join schema enforces uniqueness only at the application level via attach/4's last-write-wins replacement), the most-recent row by :inserted_at is returned.

This helper does not issue a write or a side-effect query; it is safe to call in render paths.

Examples

# Requires a configured Rindle repo + an existing owner record.
iex> attachment = Rindle.attachment_for(%MyApp.User{id: user_id}, "avatar")
iex> attachment && attachment.slot
"avatar"

cancel_processing(asset_or_id)

@spec cancel_processing(Rindle.Domain.MediaAsset.t() | binary()) ::
  :ok | {:error, :not_processing}

Cancels active variant processing for an asset.

Returns :ok when the asset has queued or executing variant work that can be cancelled. Returns {:error, :not_processing} when the asset has no queued or executing variant work.

The public surface remains asset-scoped; callers do not need to know variant ids, job ids, or Oban internals to stop in-flight processing.

Examples

iex> Rindle.cancel_processing(asset_id)
:ok

iex> Rindle.cancel_processing("missing-or-idle-asset")
{:error, :not_processing}

cancel_resumable_session(session_id, opts \\ [])

@spec cancel_resumable_session(
  binary(),
  keyword()
) :: Rindle.Upload.Broker.cancel_resumable_result()

Cancels a broker-owned resumable upload session.

complete_multipart_upload(session_id, parts, opts \\ [])

@spec complete_multipart_upload(binary(), [map()], keyword()) ::
  Rindle.Upload.Broker.verify_result()

Completes a multipart upload through the broker and reuses upload verification.

delete(profile, key, opts \\ [])

@spec delete(module(), String.t(), keyword()) :: storage_result()

Deletes an object through the profile-specific storage adapter.

Examples

# Requires a configured storage adapter.
iex> {:ok, _} = Rindle.delete(MyApp.MediaProfile, "uploads/abc.png")
iex> :ok
:ok

detach(owner, slot, opts \\ [])

@spec detach(struct(), String.t(), keyword()) :: :ok | {:error, term()}

Detaches any MediaAsset from an owner at a specific slot and triggers a purge.

Idempotent: returns :ok even when no attachment exists at the slot.

Examples

# Requires a configured Rindle repo + an existing attachment at owner+slot.
iex> :ok = Rindle.detach(%MyApp.User{id: user_id}, "avatar")
iex> :ok
:ok

detach!(owner, slot, opts \\ [])

@spec detach!(struct(), String.t(), keyword()) :: :ok

Same as detach/3 but raises Rindle.Error on failure.

download(profile, key, destination_path, opts \\ [])

@spec download(module(), String.t(), Path.t(), keyword()) :: storage_result()

Downloads an object through the profile-specific storage adapter.

Examples

# Requires a configured storage adapter and an existing object.
iex> {:ok, _meta} = Rindle.download(MyApp.MediaProfile, "uploads/abc.png", "/tmp/abc.png")
iex> :ok
:ok

erase_batch_owner_erasure(owners, opts \\ [])

@spec erase_batch_owner_erasure(
  [struct()],
  keyword()
) :: batch_owner_erasure_result()

Erases multiple owners' Rindle-managed attachment rows and enqueues orphan-only purge work.

Processes each deduped owner sequentially via OwnerErasure.execute/2, one transaction per owner. Empty batches and over-limit owner counts are rejected before any planner work runs.

On per-owner failure after earlier owners succeeded, returns {:error, {:batch_owner_failed, detail}} with partial_report containing only completed owners; earlier owners remain committed.

erase_owner(owner, opts \\ [])

@spec erase_owner(struct(), keyword()) ::
  {:ok, owner_erasure_report()} | {:error, term()}

Erases an owner's Rindle-managed attachment rows and enqueues orphan-only purge work.

Execute semantics stay honest: attachment detach happens transactionally, while storage deletion remains asynchronous through PurgeStorage.

head(profile, key, opts \\ [])

@spec head(module(), String.t(), keyword()) :: storage_result()

Checks for object existence through the profile-specific storage adapter.

Examples

# Requires a configured storage adapter.
iex> {:ok, _meta} = Rindle.head(MyApp.MediaProfile, "uploads/abc.png")
iex> :ok
:ok

initiate_multipart_upload(profile, opts \\ [])

@spec initiate_multipart_upload(
  module(),
  keyword()
) :: Rindle.Upload.Broker.initiate_multipart_result()

Initiates a multipart direct upload session through the broker.

initiate_resumable_session(profile, opts \\ [])

@spec initiate_resumable_session(
  module(),
  keyword()
) :: Rindle.Upload.Broker.initiate_resumable_result()

Initiates a resumable upload session through the broker.

initiate_tus_upload(profile, opts \\ [])

@spec initiate_tus_upload(
  module(),
  keyword()
) :: Rindle.Upload.TusPlug.create_upload_result()

Initiates a tus upload resource through the broker and returns the signed upload URL needed by browser tus clients.

Requires an explicit mounted tus :path, the adopter :secret_key_base, and the file :length in bytes. The returned upload_url is a bearer credential and should only be handed to the client that will upload the file.

initiate_upload(profile, opts \\ [])

@spec initiate_upload(
  module(),
  keyword()
) :: {:ok, Rindle.Domain.MediaUploadSession.t()} | {:error, term()}

Initiates a direct upload session through the broker.

Delegates to Broker.initiate_session/2. Returns {:ok, %MediaUploadSession{}} on success.

Examples

# Requires `config :rindle, :repo, MyApp.Repo` and a configured profile module.
iex> {:ok, session} = Rindle.initiate_upload(MyApp.MediaProfile, filename: "photo.png")
iex> session.state
"initialized"

presigned_put(profile, key, expires_in, opts \\ [])

@spec presigned_put(module(), String.t(), pos_integer(), keyword()) ::
  storage_result()

Generates a presigned PUT payload through the profile-specific storage adapter.

Examples

# Requires an S3-compatible storage adapter with :presigned_put capability.
iex> {:ok, %{url: url}} = Rindle.presigned_put(MyApp.MediaProfile, "uploads/abc.png", 3600)
iex> is_binary(url)
true

preview_batch_owner_erasure(owners, opts \\ [])

@spec preview_batch_owner_erasure(
  [struct()],
  keyword()
) :: batch_owner_erasure_result()

Plans batch owner/account erasure without changing DB or storage state.

Processes each deduped owner sequentially via OwnerErasure.preview/2 in its own read-only planner pass. Empty batches and over-limit owner counts are rejected before any planner work runs.

On per-owner failure after earlier owners succeeded, returns {:error, {:batch_owner_failed, detail}} with partial_report containing only completed owners.

preview_owner_erasure(owner, opts \\ [])

@spec preview_owner_erasure(struct(), keyword()) ::
  {:ok, owner_erasure_report()} | {:error, term()}

Plans owner/account erasure without changing DB or storage state.

Returns the same semantic report vocabulary as erase_owner/2, but with mode: :preview and no purge work enqueued.

ready_variants_for(asset_or_id)

@spec ready_variants_for(Rindle.Domain.MediaAsset.t() | binary()) :: [
  Rindle.Domain.MediaVariant.t()
]

Lists MediaVariant rows in the "ready" state for a given asset.

Accepts either a %MediaAsset{} struct or a binary asset id. Returns a list of variants ordered by :name ascending; returns [] when no variants are ready. The unique constraint on (asset_id, name) makes the ordering deterministic.

Only variants in the "ready" state are returned — variants in "planned", "queued", "processing", "stale", "missing", "failed", or "purged" are excluded. Adopters wanting fallback behavior should call variant_url/4, which already orchestrates the stale-policy fallback.

Examples

# Requires a configured Rindle repo + at least one ready variant row.
iex> variants = Rindle.ready_variants_for(asset)
iex> Enum.all?(variants, &(&1.state == "ready"))
true

reprobe(asset_or_id)

@spec reprobe(Rindle.Domain.MediaAsset.t() | binary()) ::
  {:ok, Rindle.Ops.LifecycleRepair.reprobe_report()} | {:error, term()}

Reruns probe detection for an asset and persists only probe-derived fields.

Accepts either a %MediaAsset{} struct or a binary asset id. Reprobe is asset-scoped and refreshes only content_type, kind, width, height, duration_ms, has_video_track, and has_audio_track; fields that no longer apply are cleared explicitly, while unrelated lifecycle state and ownership data stay untouched.

Returns {:ok, report} on a completed probe refresh and {:error, reason} when the run could not be completed.

Examples

iex> {:ok, report} = Rindle.reprobe(asset_id)
iex> report.kind
"image"

requeue_variants(asset_or_id, opts \\ [])

@spec requeue_variants(Rindle.Domain.MediaAsset.t() | binary(), keyword() | map()) ::
  {:ok, Rindle.Ops.LifecycleRepair.requeue_report()} | {:error, term()}

Requeues failed or cancelled variants for a single asset.

Accepts either a %MediaAsset{} struct or a binary asset id. By default, only this asset's variants currently in failed or cancelled state are targeted. Pass variant_names: [...] to narrow the repair to explicit variant names; unknown names fail loudly, and already-ready siblings stay untouched.

Returns {:ok, report} after the enqueue attempt finishes, including deterministic counters for selected, enqueued, skipped, and errored variants. Equivalent in-flight jobs are counted as skipped through Oban uniqueness rather than double-enqueued.

Examples

iex> {:ok, report} = Rindle.requeue_variants(asset_id)
iex> report.enqueued
1

iex> {:ok, report} = Rindle.requeue_variants(asset_id, variant_names: ["thumb"])
iex> report.selected
1

resumable_session_status(session_id, opts \\ [])

@spec resumable_session_status(
  binary(),
  keyword()
) :: Rindle.Upload.Broker.resumable_status_result()

Polls the broker-owned resumable upload session without changing completion trust.

runtime_status(opts \\ [])

@spec runtime_status(keyword() | map()) :: {:ok, map()} | {:error, term()}

Returns a bounded runtime diagnostics report for operators.

The report is read-only and groups lifecycle drift, stuck work, and upload residue into a stable map shape with counts, oldest age, and bounded examples. Supported filters are intentionally narrow: :profile, :older_than, :limit, and :format.

Examples

iex> {:ok, report} = Rindle.runtime_status(limit: 3)
iex> is_map(report.variants)
true

sign_multipart_part(session_id, part_number, opts \\ [])

@spec sign_multipart_part(binary(), pos_integer(), keyword()) ::
  Rindle.Upload.Broker.sign_part_result()

Signs a single multipart upload part through the broker.

storage_adapter_for(profile)

@spec storage_adapter_for(module()) :: module()

Resolves the storage adapter module for a given profile.

Examples

# Requires a profile module that defines `storage_adapter/0`.
iex> Rindle.storage_adapter_for(MyApp.MediaProfile)
Rindle.Storage.Local

store(profile, key, source_path, opts \\ [])

@spec store(module(), String.t(), Path.t(), keyword()) :: storage_result()

Stores an object through the profile-specific storage adapter.

Examples

# Requires a configured storage adapter and a readable source file.
iex> {:ok, _meta} = Rindle.store(MyApp.MediaProfile, "uploads/abc.png", "/tmp/abc.png")
iex> :ok
:ok

store_variant(profile, key, source_path, opts \\ [])

@spec store_variant(module(), String.t(), Path.t(), keyword()) :: storage_result()

Executes variant storage and logs failures with required context metadata.

Wraps store/4 with structured failure logging that captures the asset_id and variant_name for observability dashboards.

Examples

# Requires a configured storage adapter.
iex> {:ok, _meta} = Rindle.store_variant(MyApp.MediaProfile, "variants/abc-thumb.png", "/tmp/abc-thumb.png", asset_id: asset_id, variant_name: "thumb")
iex> :ok
:ok

upload(profile_module, upload, opts \\ [])

@spec upload(module(), map() | Plug.Upload.t(), keyword()) ::
  {:ok, Rindle.Domain.MediaAsset.t()} | {:error, term()}

Uploads a file directly through the server (proxied upload).

Accepts a profile module and an upload (map or %Plug.Upload{}). The file is validated against the profile's upload_policy/0, stored via the profile's storage adapter, and a MediaAsset row is inserted in the analyzing state.

Examples

# Requires a configured Rindle repo + a configured storage adapter + a Plug.Upload.
iex> {:ok, asset} = Rindle.upload(MyApp.MediaProfile, %Plug.Upload{path: "/tmp/x.png", filename: "x.png"})
iex> asset.state
"analyzing"

upload!(profile_module, upload, opts \\ [])

@spec upload!(module(), map() | Plug.Upload.t(), keyword()) ::
  Rindle.Domain.MediaAsset.t()

Same as upload/3 but raises Rindle.Error on failure, Ecto.InvalidChangesetError for changeset failures, or re-raises the original exception for storage adapter exceptions.

url(profile, key, opts \\ [])

@spec url(module(), String.t(), keyword()) :: storage_result()

Generates a delivery URL through the profile-specific storage adapter.

Delegates to Rindle.Delivery.url/3 so policy (public vs. signed) is honored.

Examples

# Requires a configured storage adapter and a key that exists in storage.
iex> {:ok, url} = Rindle.url(MyApp.MediaProfile, "uploads/abc.png")
iex> is_binary(url)
true

url!(profile, key, opts \\ [])

@spec url!(module(), String.t(), keyword()) :: String.t()

Same as url/3 but raises Rindle.Error on failure or re-raises the original exception for storage adapter exceptions.

variant_url(profile, asset, variant, opts \\ [])

@spec variant_url(module(), map(), map(), keyword()) :: storage_result()

Generates a delivery URL for a variant, falling back when needed.

Delegates to Rindle.Delivery.variant_url/4. Stale or non-ready variants fall back to the original asset URL per the configured stale-serving policy.

Examples

# Requires a configured storage adapter and ready/stale variant rows.
iex> {:ok, url} = Rindle.variant_url(MyApp.MediaProfile, asset, variant)
iex> is_binary(url)
true

variant_url!(profile, asset, variant, opts \\ [])

@spec variant_url!(module(), map(), map(), keyword()) :: String.t()

Same as variant_url/4 but raises Rindle.Error on failure or re-raises the original exception for storage adapter exceptions.

verify_completion(session_id, opts \\ [])

@spec verify_completion(
  binary(),
  keyword()
) :: Rindle.Upload.Broker.verify_result()

Verifies a direct upload completion through the broker.

Delegates to Broker.verify_completion/2. Promotes the session to completed and the asset to validating.

Examples

# Requires a configured Rindle repo + the upload object to exist in storage.
iex> {:ok, %{session: session, asset: asset}} = Rindle.verify_completion(session_id)
iex> session.state
"completed"
iex> asset.state
"validating"

verify_upload(session_id, opts \\ [])

This function is deprecated. Use verify_completion/2.
@spec verify_upload(
  binary(),
  keyword()
) :: Rindle.Upload.Broker.verify_result()

Verifies a direct upload completion through the broker.

Legacy compatibility shim for 0.1.x. Delegates to verify_completion/2 while the older name remains supported.

Examples

# Requires a configured Rindle repo + the upload object to exist in storage.
iex> {:ok, %{session: session, asset: asset}} = Rindle.verify_upload(session_id)
iex> session.state
"completed"
iex> asset.state
"validating"

version()

@spec version() :: String.t()

Returns the current version of Rindle.

Examples

iex> is_binary(Rindle.version())
true