AshArcadic.Replicant.Resolver (AshArcadic v0.2.0)

Copy Markdown View Source

Runtime resolution for the AshArcadic.Replicant sink — the tenant/classification layer over compiled resource metadata. Pure functions (no DB access):

  • build_index/1 — reflect the configured domains into a {source_schema, source_table} => resource index, failing closed on a duplicate source key (an ambiguous route: two mirrors claiming one Postgres table).
  • lookup/3 — resolve a source {schema, table} to its mirror resource, applying the SAME nil-schema → "public" default the index keys use.
  • resolve_tenant/2 / resolve_tenant!/3 — per-row tenant from the resource's replicant tenant_attribute, failing closed with :tenant_required on any value Ash would treat as unscoped (nil, false, or blank). resolve_tenant!/3 is the raising variant every apply path shares.
  • writable_target/2 / attrs_for_upsert/2 — map source string columns to their writable target attributes, dropping replicant-skip columns and undeclared columns, and HALTING value-free (F5) when a non-skipped column maps to a sensitive target (never emit plaintext into a classified column).
  • primary_key/1 / pk_values/2.

Diverges from the sibling ash_replicant's AshReplicant.Resolver (the shape template) deliberately:

  • Source records are string-keyed (Postgres column names as binaries), so every lookup is Map.get(record, to_string(attr)).
  • No tenant_mfa — the replicant extension exposes only tenant_attribute.
  • No AshCloak cloak-routing. AshArcadic has no key material, so a plaintext value bound for a sensitive target fails closed (F5) rather than being re-encrypted.

Summary

Functions

Map one string-keyed source record to {inputs, upsert_fields} for the mirror upsert. Drops replicant-skip columns and undeclared columns; unchanged-TOAST columns are absent from record (surfaced separately on %Replicant.Change{}'s unchanged list, never in record) so iterating the map excludes them naturally. HALTS value-free (F5) on a non-skipped column mapped to a sensitive target.

Look up the mirror resource for a source {schema, table} in an index built by build_index/1, applying the SAME nil-schema → "public" default the index keys use (so the convention lives in one place next to the builder). Returns the resource, or nil for an unmapped table.

The fail-closed bang variant of resolve_tenant/2: returns the per-row tenant, or raises a value-free AshArcadic.Replicant.Error (reason: :tenant_required) when the row carries no usable tenant. op labels the failing sink operation (:upsert / :destroy / ...) in the structural error, which NEVER carries the record or the tenant value. The single tenant-resolution entry point shared by every apply path, so :tenant_required fails identically everywhere.

Types

source_key()

@type source_key() :: {schema :: String.t(), table :: String.t()}

Functions

attrs_for_upsert(resource, record)

@spec attrs_for_upsert(module(), map()) :: {map(), [atom()]}

Map one string-keyed source record to {inputs, upsert_fields} for the mirror upsert. Drops replicant-skip columns and undeclared columns; unchanged-TOAST columns are absent from record (surfaced separately on %Replicant.Change{}'s unchanged list, never in record) so iterating the map excludes them naturally. HALTS value-free (F5) on a non-skipped column mapped to a sensitive target.

build_index(domains)

@spec build_index([module()]) ::
  {:ok, %{required(source_key()) => module()}}
  | {:error, {:duplicate_source, source_key()}}
  | {:error, {:missing_source_table, module()}}

lookup(index, schema, table)

@spec lookup(%{required(source_key()) => module()}, String.t() | nil, String.t()) ::
  module() | nil

Look up the mirror resource for a source {schema, table} in an index built by build_index/1, applying the SAME nil-schema → "public" default the index keys use (so the convention lives in one place next to the builder). Returns the resource, or nil for an unmapped table.

pk_values(resource, record)

@spec pk_values(module(), map()) :: map()

primary_key(resource)

@spec primary_key(module()) :: [atom()]

resolve_tenant(resource, record)

@spec resolve_tenant(module(), map()) :: {:ok, term()} | {:error, :tenant_required}

resolve_tenant!(resource, record, op)

@spec resolve_tenant!(module(), map(), atom()) :: term()

The fail-closed bang variant of resolve_tenant/2: returns the per-row tenant, or raises a value-free AshArcadic.Replicant.Error (reason: :tenant_required) when the row carries no usable tenant. op labels the failing sink operation (:upsert / :destroy / ...) in the structural error, which NEVER carries the record or the tenant value. The single tenant-resolution entry point shared by every apply path, so :tenant_required fails identically everywhere.

writable_target(resource, source_col)

@spec writable_target(module(), String.t()) :: {:ok, atom()} | :skip