Upgrading to v0.3

View Source

v0.3.0 follows dowser_client 0.4, and brings these changes with it:

  • Repos replace contexts. A cluster is a module of your own — use Dowser.OpenSearch.Repo — configured under your application and started in your supervision tree, like an Ecto.Repo. The :context option is now :repo.
  • The casting moved to dowser_client, shared with dowser_elasticsearch. Dowser.OpenSearch.Codec and Dowser.OpenSearch.MappingCacher stay; the field codecs became dowser_client's field types, and date casting is now limited to nine formats.
  • New casting options. :codec is now :types, and :struct, :unknown_fields and a module for :mapping join it.
  • Your application starts the mapping cacher; this package no longer does.

Start with the table, then read Watch out for silence: the date changes return different values without raising anything. dowser_client's own UPGRADE_GUIDE_0_4 has the transport-level details, for code that calls Dowser.Client directly.

At a glance

v0.2v0.3
config :dowser_client, contexts: [default: [...]]use Dowser.OpenSearch.Repo, otp_app: :my_app + config :my_app, MyApp.Search, ...
the :default contextconfig :dowser_opensearch, default_repo: MyApp.Search
context: :logs / context: [endpoint: ...]repo: MyApp.Logs / repo: [endpoint: ...]
Dowser.OpenSearch.Codec.Date (and Binary, DateRange, GeoPoint, IP, Range)Dowser.Type.Date (and so on)
@behaviour Dowser.OpenSearch.Codec@behaviour Dowser.Type
Dowser.OpenSearch.Codec.load/2, dump/2Dowser.Type.load/2, dump/2
Dowser.OpenSearch.MappingErrorDowser.MappingError
codec: MyApp.Type (per request, on the codec, or config :dowser_opensearch, codec:)types: MyApp.Type
the mapping cacher, started by this packagestarted by your application: children = [..., Dowser.OpenSearch.MappingCacher]
config :dowser_opensearch, mapping_cacher_opts: [...]the child's options: {Dowser.OpenSearch.MappingCacher, ttl: ...}
MappingCacher.put(index, mapping, context: ...)MappingCacher.put(index, mapping, repo: ...) — required
flattened and flat_object opaqueflat_object opaque — flattened is Elasticsearch's

What stays: config :dowser_opensearch, mappings: / mapping_failure:, the [:dowser_opensearch, :mapping, :failure] telemetry event, Dowser.OpenSearch.Repository, and every API function's signature.

1. Bump the dependency

{:dowser_opensearch, "~> 0.3.0"}
mix deps.update dowser_opensearch dowser_client

2. Define a repo

Each context becomes a module:

defmodule MyApp.Search do
  use Dowser.OpenSearch.Repo, otp_app: :my_app
end

Its configuration moves to your application, under the module's name — the keys are the same — and the repo the default context stood for becomes :default_repo:

- config :dowser_client,
-   contexts: [
-     default: [
-       endpoint: "http://localhost:9200",
-       decoder: Dowser.OpenSearch.Codec,
-       encoder: Dowser.OpenSearch.Codec
-     ]
-   ]
+ config :my_app, MyApp.Search,
+   endpoint: "http://localhost:9200",
+   decoder: Dowser.OpenSearch.Codec,
+   encoder: Dowser.OpenSearch.Codec
+
+ config :dowser_opensearch, default_repo: MyApp.Search

Start it with the rest of your application:

children = [MyApp.Repo, MyApp.Search, MyAppWeb.Endpoint]

A repo that isn't started still works — it resolves its configuration on every request instead of once — so scripts and Mix tasks need nothing special.

Three things a repo adds:

  • init/2, for configuration that can't live in a config file — see Dowser.Repo;
  • dynamic instances: the same module started again under other names — a cluster per tenant — chosen per process (put_dynamic_repo/1) or per call (repo: {MyApp.Search, :tenant_a});
  • verify: true, which makes the repo check at startup that its endpoint really is OpenSearch (GET / reports "distribution": "opensearch"), and fail to start otherwise. MyApp.Search.engine() then returns the version it found. It is off by default: a cluster that is down would stop your application from booting.

Start the mapping cacher yourself

This package used to start Dowser.OpenSearch.MappingCacher on its own. It no longer does: if you cast against the cluster's mappings — a repo with decoder:/encoder: set to Dowser.OpenSearch.Codec — start it with your repo, and move its options there:

- config :dowser_opensearch, mapping_cacher_opts: [ttl: :timer.minutes(10)]

  children = [
    MyApp.Search,
+   {Dowser.OpenSearch.MappingCacher, ttl: :timer.minutes(10)}
  ]

A cacher that isn't started now fails the request with a Dowser.MappingError naming it, where v0.2 would have left every value uncast. You don't need it at all if you give the mappings yourself — config :dowser_opensearch, mapping: MyApp.Mappings for the whole application, or struct: / mapping: per call.

3. :context is :repo

- Dowser.OpenSearch.Search.search(query, index: "logs-*", context: :logs)
+ Dowser.OpenSearch.Search.search(query, index: "logs-*", repo: MyApp.Logs)

A call without :repo goes to :default_repo, as it went to the :default context. A call that still passes :context fails with an error naming :repo; a call with neither, and no :default_repo configured, fails with :no_repo. The test suite finds them all.

The same goes for Dowser.OpenSearch.Repository modules: their functions take repo: where they took context:.

A repo also exposes the HTTP verbs, for an endpoint this package doesn't cover:

MyApp.Search.post("/_plugins/_ml/models/_search", %{query: %{match_all: %{}}})

4. Field codecs are field types

The per-type modules moved to dowser_client, and the behaviour with them:

  defmodule MyApp.Type do
-   @behaviour Dowser.OpenSearch.Codec
+   @behaviour Dowser.Type

    @impl true
    def load(value, %{"type" => "scaled_float", "scaling_factor" => factor}), do: value / factor
-   def load(value, field), do: Dowser.OpenSearch.Codec.load(value, field)
+   def load(value, field), do: Dowser.Type.load(value, field)

    @impl true
    def dump(value, %{"type" => "scaled_float", "scaling_factor" => factor}), do: round(value * factor)
-   def dump(value, field), do: Dowser.OpenSearch.Codec.dump(value, field)
+   def dump(value, field), do: Dowser.Type.dump(value, field)
  end

Dowser.OpenSearch.Codec is still the module a repo names as :decoder and :encoder, but it no longer has load/2, dump/2 or a behaviour of its own.

The option pointing it at your type module is now :types — per request, on the codec, or globally:

- Document.get("posts", "1", codec: MyApp.Type)
+ Document.get("posts", "1", types: MyApp.Type)

- decoder: {Dowser.OpenSearch.Codec, codec: MyApp.Type}
+ decoder: {Dowser.OpenSearch.Codec, types: MyApp.Type}

- config :dowser_opensearch, codec: MyApp.Type
+ config :dowser_opensearch, types: MyApp.Type

A request that still passes codec: fails with an error naming :types, and so does a codec or an application environment still configured with it — a :codec left in place would otherwise be ignored, and every value come back uncast.

New casting options

:types sits with four more options, all set the same three ways — see Dowser.OpenSearch.Codec:

  • :mapping takes a module now, not only a map: a Dowser.Mapping implementation, or a document module (its own mapping);
  • :struct loads every document into a document module's struct (Dowser.Schema), and dumps it back;
  • :unknown_fields: :raise returns a Dowser.UnknownFieldError for a field the mapping doesn't declare (the default, :keep, is what v0.2 did);
  • :mapping_failure is unchanged.

5. MappingError moved

- rescue error in Dowser.OpenSearch.MappingError -> ...
+ rescue error in Dowser.MappingError -> ...

- {:error, %Dowser.OpenSearch.MappingError{index: index}} -> ...
+ {:error, %Dowser.MappingError{index: index}} -> ...

6. Tests

MappingCacher.put/3 needs the repo the entry belongs to, and raises without one. In v0.2 an entry put without a context was keyed under the :default context, and when there was none, nothing was stored at all:

- Dowser.OpenSearch.MappingCacher.put("posts", mapping)
+ Dowser.OpenSearch.MappingCacher.put("posts", mapping, repo: MyApp.Search)

config :dowser_opensearch, mappings: %{...} is unchanged, and needs no running cacher.

Watch out for silence

Nothing below raises: the same document simply comes back with different values. Grep your mappings, then your code.

Dates are cast for nine formats only

A date field is cast only when its mapping names one of these formats, each read by the Elixir function that parses it:

formatElixir
epoch_millis, epoch_secondDateTime
strict_dateDate
strict_date_hour_minute_second, …_millisNaiveDateTime
strict_date_time_no_millis, strict_date_timeDateTime
strict_hour_minute_second, …_millisTime

Any other format is returned as stored — and so is a date field with no format at all, which OpenSearch reads as strict_date_optional_time||epoch_millis. In v0.2 that default was cast, so a field like "published_at": {"type": "date"} used to come back as a DateTime and now comes back as a string.

For a new index, give each date field one of the nine formats. An existing index can't change a field's format in place, so until it is reindexed, a type module can tell the casting which formats to read the field with:

defmodule MyApp.Type do
  @behaviour Dowser.Type

  # Fields mapped without a format: read them as date-times, dates or epoch
  # millis — written back as date-times.
  @default %{"format" => "strict_date_time||strict_date||epoch_millis"}

  @impl true
  def load(value, %{"type" => "date"} = field) when not is_map_key(field, "format"),
    do: Dowser.Type.load(value, Map.merge(field, @default))

  def load(value, field), do: Dowser.Type.load(value, field)

  @impl true
  def dump(value, %{"type" => "date"} = field) when not is_map_key(field, "format"),
    do: Dowser.Type.dump(value, Map.merge(field, @default))

  def dump(value, field), do: Dowser.Type.dump(value, field)
end
config :dowser_opensearch, types: MyApp.Type

Dates are written exactly, and in UTC

  • A date is written at exactly its format's precision: truncated to the second or the millisecond, and padded to three digits for a _millis format — ~U[2026-08-11 00:00:00Z] is written "2026-08-11T00:00:00.000Z" to strict_date_time, where v0.2 wrote "2026-08-11T00:00:00Z".
  • A DateTime in another time zone is written as its UTC instant (Z). A stored offset (-04:00) is still applied and dropped on the way in, and -00:00 is now read as UTC rather than left as a string.

Ranges are cast only when the struct holds them exactly

A date_range becomes a Date.Range only when it is mapped strict_date; with any other format, or none, it comes back as stored. v0.2 cast a format-less range whose bounds were plain dates, and raised on one whose bounds had a time. An integer_range (and now a long_range) becomes a Range only with integer gte and lte bounds. An open-ended range, or gt/lt bounds, come back as stored.

The mapping decides what a value is

  • A geo_point stored as {"lat": …, "lon": …} now loads to {lat, lon}. v0.2 never handed that shape to its type, so it stayed a map.
  • A map its type leaves uncast — a geo_shape, an integer_range with float bounds — has its keys go through :keys like the rest of the document.
  • A field the mapping doesn't know keeps its value as is. v0.2 walked it against its parent's fields.
  • A flattened field is no longer opaque — it is Elasticsearch's type, not OpenSearch's. A flat_object field still is.
  • A struct sent as a document is now cast against the mapping, as dowser_elasticsearch already did: under an object it is turned into a map and walked, under a leaf it goes to its type.