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 passestxback torun/2andsave/3doesn't change. - The
:typesconfig (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") raisesArgumentError. So do a sort direction other than:asc/:desc, an unknown join type or document operator, and a non-integerlimit/offset. SQL fragments you write yourself are still used as written. filter(col: nil)meanscol IS NULL(#35). Before, it builtcol = $1, which never matches anything.run/1on a statement without rows returns{:ok, []}, not a bare[].run_batch/1andtransact_batch/1return{:ok, %Moebius.Result{}}per command, instead of Postgrex result structs.- An exception raised inside
transaction/1is re-raised after the rollback. Before, exceptions with a:messagefield were turned into{:error, message}, which hid bugs. Database errors,rollback/1andthrow({:error, reason})still return{:error, _}. - Explicit connection options win over the parts of
:url. Before,urlsilently overrodeport:and friends. - New document tables use
id bigint generated by default as identityandupdated_at timestamptz not null default now(). Existing tables are untouched. The search column is built frombody ->> 'field'(plain text, without JSON quotes). - Full-text search takes what people type.
search/2(both builders) useswebsearch_to_tsquery, so"red shoes","O'Brien"and"apple -pie"work. Before,to_tsqueryraised a syntax error on anything but a single word or hand-written tsquery. count/1ignoressort,limitandoffset. Before,sort |> countbuilt invalid SQL (select count(1) ... order by).- Empty builder input raises.
insert([]),update([])andbulk_insert([])raiseArgumentErrorinstead of buildingvalues($1, $0);filter([])leaves the command alone instead of emittingwhere ;. - Numeric strings longer than 34 digits are refused as parameters. This is decimal 3's
protection against CVE-2026-32686. Pass a
Decimalbuilt withmax_digits: :infinityif you really mean it. Values read from the database keep full precision. - Deprecated:
Moebius.run_with_psql/2(useMoebius.run_script/2, which doesn't needpsqlinstalled) andMoebius.pool_opts/0(pool options go in the connection options).
Fixed
bulk_insert/2read 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 byparse_connection/1. Bad URLs raiseArgumentError(wasRuntimeError). - Document field names may not contain a backslash. With
standard_conforming_stringsoff (a legacy server setting) a backslash could end the quoted key early. stream/2checks:chunkis a positive integer before it goes intoFETCH.- 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 lazyStreamincluded) with Postgres's binaryCOPYprotocol. 100,000 rows load in about 130ms, 4.6x faster than 4.x'sbulk_inserton 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: trueruns 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/1aborts the current transaction with a reason.- Queries in the same process join an open transaction even without the
txargument. pool_status/0: pool size and usage.- The pool opens all
pool_sizeconnections at start (as Postgrex did); setpool_minlower 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. numericdecodes to an exactDecimaland encodes fromDecimal, integers, floats or numeric strings. Timestamps are exact to the microsecond (they're read as integers, not float seconds).infinitydates and timestamps are supported.tsvectorcolumns come back as text instead of raising.mix moebius.create/drop/migrate/seedno longer needpsqlinstalled.
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_batchof 100k rows: about 24% slower (739ms vs 595ms). Usecopy/3instead, at 130ms.
Fixed
find/2put the id into the SQL (find("1 or 1=1")returned a row). It's a parameter now.DocumentQuery.contains/2put 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.DocumentQueryfield names infilter/4,exists/3,sort/3andsearch/2are 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: [])builtIN(), a syntax error. It now matches nothing (andnot_in: []matches everything).- The
DBConnection.TransactionErrorlog noise after a failed transaction is gone. - The
Moebius.QueryFilterdoctests were wrong and weren't run. They run now.