5.0.0

Moebius now runs on epgsql 4.8 with a pooler 1.7 connection pool, instead of Postgrex. Both are past 1.0 and neither has dependencies of its own. The runtime dependency tree went from postgrex, db_connection, telemetry and decimal 2 to epgsql, pooler, jason and decimal 3. That also closes CVE-2026-32687, CVE-2026-58225 (both in Postgrex) and CVE-2026-32686 (in decimal 2).

The query builders and the run/first/find/save/transaction API are unchanged. The breaking changes are listed below, each with its reason.

Breaking

  • The transaction handle is a %Moebius.Connection{}, not a %DBConnection{}. Code that only passes tx back to run/2 and save/3 doesn't change.
  • The :types config (Postgrex extensions) is gone. Types are handled by Moebius's own epgsql codecs; see the README for what each Postgres type becomes.
  • Names are checked. A table, column, function or SQL file name that isn't a plain name (users, membership.users, "Order Items") raises ArgumentError. So do a sort direction other than :asc/:desc, an unknown join type or document operator, and a non-integer limit/offset. SQL fragments you write yourself are still used as written.
  • filter(col: nil) means col IS NULL (#35). Before, it built col = $1, which never matches anything.
  • run/1 on a statement without rows returns {:ok, []}, not a bare [].
  • run_batch/1 and transact_batch/1 return {:ok, %Moebius.Result{}} per command, instead of Postgrex result structs.
  • An exception raised inside transaction/1 is re-raised after the rollback. Before, exceptions with a :message field were turned into {:error, message}, which hid bugs. Database errors, rollback/1 and throw({:error, reason}) still return {:error, _}.
  • Explicit connection options win over the parts of :url. Before, url silently overrode port: and friends.
  • New document tables use id bigint generated by default as identity and updated_at timestamptz not null default now(). Existing tables are untouched. The search column is built from body ->> 'field' (plain text, without JSON quotes).
  • Full-text search takes what people type. search/2 (both builders) uses websearch_to_tsquery, so "red shoes", "O'Brien" and "apple -pie" work. Before, to_tsquery raised a syntax error on anything but a single word or hand-written tsquery.
  • count/1 ignores sort, limit and offset. Before, sort |> count built invalid SQL (select count(1) ... order by).
  • Empty builder input raises. insert([]), update([]) and bulk_insert([]) raise ArgumentError instead of building values($1, $0); filter([]) leaves the command alone instead of emitting where ;.
  • Numeric strings longer than 34 digits are refused as parameters. This is decimal 3's protection against CVE-2026-32686. Pass a Decimal built with max_digits: :infinity if you really mean it. Values read from the database keep full precision.
  • Deprecated: Moebius.run_with_psql/2 (use Moebius.run_script/2, which doesn't need psql installed) and Moebius.pool_opts/0 (pool options go in the connection options).

Fixed

  • bulk_insert/2 read values by position. A row whose keys were in a different order from the first row was inserted with its values swapped, silently. Rows are now read by key, maps are accepted, and a row missing a column raises.
  • A malformed uuid parameter crashed the connection process. It is now {:error, "parameter $1 must be uuid, ..."} like every other type mismatch.
  • A password containing : was cut at the first colon by parse_connection/1. Bad URLs raise ArgumentError (was RuntimeError).
  • Document field names may not contain a backslash. With standard_conforming_strings off (a legacy server setting) a backslash could end the quoted key early.
  • stream/2 checks :chunk is a positive integer before it goes into FETCH.
  • A pool that is busy no longer reports "isn't started"; only a missing pool process does.

Added

  • copy/3: bulk load any Enumerable (a lazy Stream included) with Postgres's binary COPY protocol. 100,000 rows load in about 130ms, 4.6x faster than 4.x's bulk_insert on the same machine. All or nothing; bad values are reported with their row and column.
  • stream/2: read any query or document query through a server-side cursor, a chunk at a time. Lazy, and halting early gives the connection back.
  • explain/2: the query plan as text. analyze: true runs the query inside a rolled-back transaction, so explaining an insert leaves nothing behind.
  • Nested transactions become savepoints, so an inner one can fail on its own.
  • rollback/1 aborts the current transaction with a reason.
  • Queries in the same process join an open transaction even without the tx argument.
  • pool_status/0: pool size and usage.
  • The pool opens all pool_size connections at start (as Postgrex did); set pool_min lower to let an idle pool shrink.
  • Connection options: pool_min, checkout_timeout, queue_max, max_lifetime, statement_timeout, lock_timeout, idle_in_transaction_session_timeout, settings, application_name, socket_dir, ssl/ssl_opts.
  • Moebius.Error, with the SQLSTATE code and name, detail, hint, constraint, table and column.
  • Parameters are checked against the types Postgres expects before they're sent, so a wrong type is a clear error (parameter $1 must be int4, got: "five") and never takes a connection down.
  • numeric decodes to an exact Decimal and encodes from Decimal, integers, floats or numeric strings. Timestamps are exact to the microsecond (they're read as integers, not float seconds). infinity dates and timestamps are supported.
  • tsvector columns come back as text instead of raising.
  • mix moebius.create/drop/migrate/seed no longer need psql installed.

Performance

Measured with the same script against 4.2 (Postgrex 0.19) on the same machine and database, both pools holding 10 open connections:

  • Single queries: median latency 5-11% lower on every operation measured (find by id 73µs vs 80µs), and p99 latency 10-42% lower (insert returning 118µs vs 205µs).
  • 50 processes sharing the pool: about 12% slower (187ms vs 167ms for 10,000 finds). epgsql encodes and decodes in the connection process, where DBConnection does it in each caller.
  • bulk_insert + transact_batch of 100k rows: about 24% slower (739ms vs 595ms). Use copy/3 instead, at 130ms.

Fixed

  • find/2 put the id into the SQL (find("1 or 1=1") returned a row). It's a parameter now.
  • DocumentQuery.contains/2 put the JSON into the SQL as a literal, so a ' in a value broke the query and could be used for injection. It's a parameter now.
  • DocumentQuery field names in filter/4, exists/3, sort/3 and search/2 are quoted, so any key is safe.
  • db(:docs) |> delete(id) |> first() ran a select and deleted nothing.
  • Saving to a new document table from several processes at once lost writes to a race in create table if not exists. Creation now takes an advisory lock.
  • Document tables with a schema (public.docs) got invalid index names.
  • A document query that failed for any reason other than a missing table retried forever.
  • filter(:col, in: []) built IN(), a syntax error. It now matches nothing (and not_in: [] matches everything).
  • The DBConnection.TransactionError log noise after a failed transaction is gone.
  • The Moebius.QueryFilter doctests were wrong and weren't run. They run now.