All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[0.1.35] - 2026-09-29
Fixed
Client.Localaccepted anyretention:for a v3 database. InfluxDB 3 takes a duration string and refuses anything else with 400serde json error: ... expected a duration. That includesretention: 3600, seconds as a v2 bucket takes them, so such a call passed in tests and failed in production. The double now applies the engine's grammar, verified on 53 values against Core: one or more<number><unit>parts, a fraction allowed, case-sensitive units, or a bare"0". It still stores no retention, so nothing expires.health/1onClient.Localanswered"version" => "local"on every profile. InfluxDB 3's/healthis a plainOK, whichClient.HTTPreports as%{"status" => "pass"}. The double now answers that shape on the v3 profiles, and InfluxDB 2's JSON shape on:v2. The contract asserts each exactly instead ofstatus in ["pass", "ok"].delete_bucket/2could delete another org's bucket. The lookup from a bucket name to its ID did not name the connection's org. When two orgs had a bucket of the same name, it took the first match: on InfluxDB 2.7 adev-influxconnection deleted the other org's bucket and left its own. The lookup andlist_buckets/1are now scoped to the connection's:org. A 404 for the org itself is passed through as the server's answer, not reported as "bucket not found".list_buckets/1returned at most 20 buckets. InfluxDB 2 pages the list, and only the first page was read, so an org with more buckets silently lost the rest. Every page is now read, 100 at a time.- Names with
&,+,#or=went to the wrong place. Query values were built withURI.encode/1, which leaves those characters alone. A write to bucketa&bwent to bucketa(into its data, if such a bucket existed),c+dwent toc d, ande#fwent toe. Query values are now form-encoded and path segments strictly encoded, for org, bucket and database names, precision, and bucket and token IDs. A v3db/rpname such asname/autogenwrites, queries and drops as before.
Changed
- The testing guide explains how to pin production's column types
(#24). Each
Client.Localstore starts with an empty schema, so a test's first write to a measurement decides its column types. A writer that sends the wrong type therefore defines the column in its own test but is refused in production. The guide now says so, with asetuprecipe that seeds one point with production's types. The double already refuses a conflicting type as InfluxDB 3 does, re-verified on every profile and on v0.1.33.
[0.1.34] - 2026-09-28
Fixed
BatchWriteroptions were not validated. Each of these was reproduced:batch_size: 0started a writer that refused every write as{:error, :buffer_full}.- A misspelt key such as
flush_interval:was silently ignored and its default used. batch_size: "10"started, then crashed the writer on its first write.
start_link/1now validates with NimbleOptions and returns{:error, %NimbleOptions.ValidationError{}}naming the option. Through a connection'sbatch_writer:config, that failsadd_connection/2(or application start). The moduledoc's option list is generated from the same schema, so it cannot drift from what is accepted.A connection that failed to start stayed registered.
ConnectionSupervisorregisters the connection and initialises the client before starting its children. When a child failed,add_connection/2returned the error but the name still resolved to a connection that was not running, andClient.Local's store stayed allocated. Both are now released, so a corrected retry under the same name starts. A name that is already running keeps its registration.
[0.1.33] - 2026-09-28
Added
Client.LocalrunsSELECT DISTINCT ON (...)(#23). The double refused everyDISTINCT ONquery. InfluxDB 3 (DataFusion) supports it, and it is the idiomatic "latest row per key" query (... ORDER BY k, time DESC). A consumer that rescued the refusal got "no rows" from its tests instead of exercising the read path. The double now keeps the first row per distinct key afterORDER BY, beforeLIMITandOFFSET, over plain or projected columns or*. It follows the engine's rules, verified against InfluxDB 3 Core:ORDER BYmust start with theONcolumns, in order (400);ORDER BYresolves against the table, not select aliases (500);- aggregates and
GROUP BYare refused (405); - an empty
ON ()is a 400; - a missing key column is the null key.
An expression in
ON, such asDATE_BIN(...), is refused by name.Writes can be all-or-nothing, and acknowledged before persistence. InfluxDB 3's
accept_partial=falseandno_sync=truewrite parameters had no way through the client;write/3now takesaccept_partial:andno_sync:(v3 only) andClient.HTTPsends them.Client.Localmodels both as verified: withaccept_partial: falsethe first bad line in line order rejects the payload with the engine's single-line"line protocol parsing error"body and stores nothing, not even schema;no_syncis accepted; a flag that is not a boolean is the engine's 400.
Changed
Breaking (tests):
Client.Localfollows SQL's identifier rules. DataFusion folds every unquoted identifier to lower case: columns, tables, aliases and CTE names. Only a double-quoted identifier is exact, and"..."is never a string. The double compared names case-sensitively and read"..."as a string literal, so it disagreed with the server both ways:SELECT Hoston a tagHostpassed against the double, but the server answersNo field named host.SELECT Kfailed against the double, but the server readsk.v AS Vanswered"V"instead of"v".WHERE k = "a"matched the stringainstead of comparing with a column.SELECT "Host"andAS "Mixed Case"were refused.
The new
Client.Local.SQLIdentifiersapplies the rules before parsing, verified against InfluxDB 3 Core. InfluxQL identifiers stay case-sensitive, as on the server.Client.Local:error messages now echo the query with its identifiers folded.Breaking (tests):
Client.Localhas no implicit"default"database. With neither:databasenor:databasesconfigured, the double wrote to and queried a"default"database, and listed it. The server has no such database.Client.HTTPreturns{:error, :no_database_specified}for the same call, so code that forgotdatabase:passed its tests and failed in production. The double now answers exactly as HTTP does:write/3,query_sql/3andexecute_sql/3return{:error, :no_database_specified}.query_sql_stream/3raisesStreamErrorof kind:no_database.query_influxql/3returns the engine's 400 (must specify a 'db' parameter...);SHOW DATABASESstill works.
Without
:database, the first of:databasesis now the default, as it already was inClient.HTTP. The Local connection map no longer carries:databases, a snapshot taken at start that went stale on every create and delete. Uselist_databases/1.Client.Local's ETS storage is its own module,InfluxElixir.Client.Local.Store. The key layout, the atomic insert rules, the duplicate-merge fast path and the deletion bookkeeping were spread throughlocal.exas raw:etspatterns (40 call sites); they now live in one 300-line module with a small API, andlocal.ex(1,583 → 1,430 lines) holds no:etscall. Behaviour is unchanged.
Fixed
A
Point'sDateTimetimestamp only worked at nanosecond precision. The encoder always wrote nanoseconds, so aBatchWriterwithwrite_opts: [precision: :second](or:millisecond,:microsecond) had every point refused by InfluxDB 3 withtimestamp, 1790596800123456000, out of range for precision: Second.LineProtocol.encode/2now takesprecision:and writes aDateTimein that unit, truncated toward the past.BatchWriterencodes with its ownwrite_optsprecision. An integer timestamp is still taken as given, since it is already in the caller's unit.Client.Localaccepted timestamps no server stores, and then crashed on the query. A timestamp is refused once scaled past a signed 64-bit count of nanoseconds, in each version's words (verified on Core and 2.7):- InfluxDB 3 answers
timestamp, N, out of range for precision: Unit, andUnable to parse timestamp valuefor anything past 64 bits. - InfluxDB 2 answers
time outside range -9223372036854775806 - 9223372036854775806, a range that excludes both int64 ends, and astrconv.ParseInterror past 64 bits.
The double used to store the scaled value, and a later
SELECTraised converting it to aDateTime. A test fixture that built timestamps past int64 by string concatenation relied on this and was fixed.- InfluxDB 3 answers
LineProtocol.encode/1produced lines no server accepts, or one both drop silently. Each case was verified on InfluxDB 3 Core and 2.7:- An integer outside 64 bits. Both reject the line.
- A name ending in a backslash. Both reject it, even though the encoder escaped the backslash.
- A measurement starting with
#. It is a comment line to both, so inside a batch it vanished while the other lines were stored. Escaping it as\#stores the backslash.
These are now refused with the existing
{:invalid_field_value, ...},{:invalid_measurement, ...},{:invalid_tag_key, ...},{:invalid_tag_value, ...}and{:invalid_field_key, ...}errors.BatchWriter.write/3returns the error to the caller.Client.Localaccepted tabs in line protocol that InfluxDB 3 refuses. InfluxDB 3 ends a name or value at an unescaped tab, but it separates sections only with spaces. So a tab in a measurement, tag, field key or after a value refuses the line, with a message that depends on where the tab stands. The double now gives each of those answers, as verified on InfluxDB 3 Core. A leading tab is whitespace, as on the engine; the double had kept it as part of the measurement name. A tab inside a quoted string is fine. InfluxDB 2 stores tabs, and so does the:v2profile.query_sql_stream/3could starve its connection pool. A stream runs its request in a producer process that holds a pool connection. There were two ways to lose it:- If the consuming process was killed mid-stream (
:kill, a brutal task shutdown), the producer waited forever for an acknowledgement, holding the connection. On a small pool, every later request was a:pool_timeout. - If the consumer stopped early (
Enum.take/2), cleanup killed the producer. Finch's pool drops the connection of an owner that dies without serving the requests already waiting for it, so a request queued at that moment timed out.
Both were reproduced on InfluxDB 3 Core with a one-connection pool. The producer now monitors its consumer, and it ends the request with
Finch.stream_while/5's{:halt, _}instead of dying, which checks the connection back in. Early-stop cleanup asks the producer to cancel, and kills it only if it has not stopped within a second (a server gone silent). Two integration tests fail against the previous client.- If the consuming process was killed mid-stream (
Flight.Client.query/3closes its gRPC channel when decoding raises. The channel was closed on every return path but not on a raise; the call now usestry/after.Enterprise
DELETEinClient.Localignored SQL's identifier rules. It is now read like aSELECT: unquoted names fold to lower case, and quoted ones are exact. Before,DELETE FROM "Cpu"looked for a measurement literally named"Cpu", quotes included, and itsWHEREcompared names case-sensitively.A
Client.Localtable ended with its last point. A table exists once its columns are in the catalog, as on the engine, so aSELECTafter an EnterpriseDELETEof every row answers[]. Before, it was a "table not found" error.Tests that proved little were tightened. Contract tests now check the engine's full error body for malformed line protocol and a created token's fields, where they used to check only for a non-empty body and a map. Two tests that duplicated stronger ones were removed. The runtime
add_connection/2test now writes and queries through the name, and checks the name is gone afterremove_connection/1.Client.Localignored InfluxDB 3's database rules. Each of the following was verified against Core:- Names. A name must start with an ASCII letter or digit and
contain only letters, digits,
_,-and at most one/(the<db>/<rp>form). The double accepted any name. It now returns the engine's 400, with the engine's message, in the engine's order. This applies tocreate_database/3, to a write that creates a database, and tostart/1, which raises. - Database limit. Core holds at most 5 databases; the
:v3_coreprofile now returns the engine's 422 for a sixth. - Missing database. A query against a database that does not
exist is the engine's 404,
{"error":"query error: database not found: <name>"}. That covers SQL of any kind, even unparseable, and InfluxQL after parsing. The double used to answer a table error, or rows forSELECT 1. _internal.list_databases/1andSHOW DATABASESinclude the engine's own_internal, and dropping it is the engine's 500.
- Names. A name must start with an ASCII letter or digit and
contain only letters, digits,
InfluxQL with
format: :csvinClient.Localaggregated strings. The inner SQL query inheritedformat:, so InfluxQL saw CSV strings before rendering them again. The format now applies once, to the InfluxQL result.Stale module examples. Three module examples no longer worked:
Write.Writerwrote with no database,Admin.Tokensused the:v3_coreprofile (tokens are Enterprise-only), andAdmin.Bucketsused:v3_core(buckets are v2). All three now run as written.A stopping
BatchWriterlost its buffer. Itsterminate/2flushed the buffer, but the writer did not trap exits. When a supervisor stopped it (application shutdown,remove_connection/1), the exit signal killed it beforeterminate/2could run, so every buffered line was lost. The tests usedGenServer.stop/1, which runsterminate/2regardless, so they never caught it. The writer now traps exits. On shutdown it writes any batch still being retried, then the buffer, once each and in the order they were written. It answers anywrite_sync/3caller waiting on those writes. The new:shutdownoption sets how long the supervisor waits (default5_000).An invalid
Pointcrashed theBatchWriter. The point was encoded inside the writer withencode!, so a point with no fields killed the process along with every other caller's buffered lines.write/3andwrite_sync/3now encode in the caller and return{:error, reason}, asLineProtocol.encode/1does.Backpressure lifted while a retry chain was still in flight. An explicit
flush/2orwrite_sync/3during a retry chain starts a second chain, but the writer tracked only one. The first chain to end cleared the marker, so writes flushed into new chains instead of being held to the10 × batch_sizebound. Each chain now keeps its own batch andwrite_sync/3caller until it ends.Client.Localignoredformat:. Over HTTP,format: :csvreturns every value as a string ("1.5","true"), but the double returned typed values. A test of CSV handling could pass against the double and fail in production. The double also returned rows for:parquetinstead of a binary, and rows for an unknown format where the engine answers 400. The newClient.Local.Formatanswers asClient.HTTPdoes, verified against InfluxDB 3 Core:- Floats render as the engine's CSV does:
1e15is"1000000000000000.0",1e16is"1e16",1.5e-5is"0.000015"and1e-6is"1e-6". Integers and booleans render as the engine's strings too. - A nested value gives the same connection error as the server's aborted body.
:parquetis refused by name.:xmlis the engine's 400.:prettyand:json_linesare{:unsupported_format, f}.
The same applies to
query_influxql/3.query_sql_stream/3ignoresformat:on both clients.- Floats render as the engine's CSV does:
An empty CSV cell was a
nilkey. A null column is absent from a JSON row, butResponseParserput an empty CSV cell into the row asnil. An empty cell is either a null or an empty string; CSV cannot tell them apart (verified on InfluxDB 3's CSV and on a v2 Fluxpivot). Empty cells are now left out, sorefute Map.has_key?/2holds for every format.Flux (HTTP) returned a newline inside a string as
\r\n. InfluxDB 2's CSV writer (Go'scsv.Writerin CRLF mode) rewrites every\nin a quoted value as\r\nand drops a bare\r(verified), so a storeds="l1\nl2"read back as"l1\r\nl2"overClient.HTTPwhileClient.Localreturned"l1\nl2".ResponseParserundoes the rewrite; both clients now return the value as stored (unless it contained a\rof its own, which the server has already dropped).transport: :flightsilently dropped every column of a type the reader did not know. Verified on InfluxDB 3:selector_*without a subscript (a Struct),array_agg(a List),time - LAG(time)(a Duration),concat/upperresults (Utf8View),CAST(... AS DATE),DECIMALandBYTEA, and literal lists and structs all came back as empty rows over Flight while HTTP returned them. The reader now walks Arrow's field nodes, buffers and variadic buffer counts depth-first and decodes each of these to exactly what HTTP returns; types it still does not decode (Interval, Time, Map, Union, ...) are an error naming the type and column instead of a vanished column. Tested against 13 recorded engine responses intest/fixtures/flightand live.Timestamps inside structs and lists were strings over HTTP.
ResponseParsernow coerces nested values, soselector_last(v, time)is%{"time" => %DateTime{}, "value" => v}on HTTP, Flight andClient.Local, which now also accepts a selector without['value' | 'time']and returns that struct, as the engine does.Client.Localrefused a line with repeated spaces between its sections (m v=1i 1) as "No fields were provided"; InfluxDB 3 accepts it (verified), and so does the double now.A schema error's
original_linewas the raw line. InfluxDB 3 reports the line as it parsed it — single spaces,v=2.0asv=2,1e3as1000, strings unquoted (verified); the double renders it the same way. Parse errors still show the raw line, as on the engine.Client.Localread three clocks. Untimed points and Fluxnow()usedSystem.system_time/1, SQLnow()usedSystem.os_time/1; the two differ by microseconds, so a point a test stamped with one clock could fall afternow()read from the other and drop out ofrange(start: -1h). The double now has one clock (Store.now_ns/0, the later of the two), and Flux's exclusivestopdefaults to a nanosecond after it, so a point written a moment ago is always in range, as on a real server.
[0.1.32] - 2026-09-25
Changed
Client.Localreads and writes are two to four times faster. Every read merged duplicate points (same measurement, tags and time), about three quarters of a query's cost at 100k points, although most measurements never hold a duplicate. A write now records each series and time with an atomicinsert_new; a second write of the same key marks the measurement, and reads merge only marked measurements. The line-protocol parser skips its escape-aware scans and unescaping when a token has no quote or backslash, and slices bytes instead of graphemes. Measured: a 50k-point write 1,962 → 697 ms, 200 small writes 40 → 16 ms,COUNTover 50k points 88 → 19 ms. Results are unchanged.
Fixed
Client.Local.execute_sql/3accepted statements InfluxDB 3 refuses. Every statement butDELETEreturned{:ok, %{"rows_affected" => 0}}, andDELETEon:v3_corereturned a bare:delete_not_supported. Verified against Core:DELETE,INSERT,UPDATEare 400Error during planning: DML not supported: Delete | Insert Into | Update;CREATE TABLE | VIEW | DATABASEandDROP TABLE | VIEWare 400... DDL not supported: CreateMemoryTable | CreateView | CreateCatalog | DropTable | DropView; anything else (ALTER,TRUNCATE) is 405This feature is not implemented: Unsupported SQL statement: <sql>; aSELECTreturns rows. The double now answers each of these as the engine does (its:v3_enterpriseDELETEis unchanged), andquery_sql/3gives a non-query the same answer, since the engine serves both from one endpoint. The contract test that should have caught this asserted{:error, _}andis_map(result); it and the other contract tests accepting any error now assert the engine's status and body.Client.HTTP.execute_sql/3returnedSELECTrows as raw JSON with string timestamps; they are now typed likequery_sql/3rows. The facade,Query.SQL.execute/3and the usage rules advertisedexecute_sqlfor "DELETE, INSERT INTO ... SELECT", which Core refuses; they now say what it accepts. Theexecute_sqlcallback returns{:ok, map() | [map()]}.Client.Localgot NULL wrong inWHERE,ORDER BYandDISTINCT. Verified against InfluxDB 3:WHERE NOT (rack = '1'),rack NOT IN (...)andv NOT BETWEEN ...returned rows where the column was null (the double's logic was two-valued);ORDER BYput a null first for strings, last for numbers and betweenfalseandtrue(term order) where the engine puts nulls last ascending and first descending, so aGROUP BYof a tag with missing values was mis-ordered too;SELECT DISTINCTdropped the all-null combination the engine returns as%{}. The evaluator now uses three-valued logic, sorting honours DataFusion's null placement andNULLS FIRST/NULLS LAST, andDISTINCTkeeps the null row.LIKE 'al\%%'matched nothing inClient.Local. The backslash escape was read as a literal backslash; it now makes the next character literal, as on the engine.Client.Localrefused valid SQL: a boolean column as a predicate (WHERE b,NOT b), the%operator and unary minus (-n). All three now work as on the engine (%takes the dividend's sign and works on floats); a non-boolean bare column is the engine's planning error.HAVINGis still refused by name.Client.Localmisparsed escaped backslashes. A string field value ending in an escaped backslash (s="ends\\",v=1i) swallowed the next field and failed the line; InfluxDB 3 storesends\. A measurement, tag key, tag value or field key ending in a backslash (m\\,t=a) was stored under a garbled name or refused with the wrong message; InfluxDB 3 refuses it with "Measurements, tag keys and values, and field keys may not end with a backslash". Both verified. Under the:v2profile a measurement keeps\\,as InfluxDB 2 does (verified), and tag and field names ending in a backslash are refused as it refuses them.- No release since 0.1.21 had a CHANGELOG heading (#22). The publish
job bumped the version and published without touching
CHANGELOG.md, so 0.1.22 through 0.1.31 were all released with their entries under[Unreleased]and a consumer could not tell which version changed what. Every entry is now under the release that first shipped it, worked out from the[Unreleased]block at each release tag (all 71 entries placed, their text unchanged). The publish job now writes## [x.y.z] - dateunder[Unreleased]before publishing, fails the release if the heading is missing, and commits it with the bump; a test fails CI if the version inmix.exshas no heading or the headings are out of order.
[0.1.31] - 2026-09-24
Fixed
query_sql_stream/3over HTTP returned timestamps as strings. Each streamed JSONL row was onlyJason.decoded, sotime(and everyDATE_BINalias orMAX(time)) was"2023-11-14T22:13:20"whilequery_sql/3andClient.Local's stream returned aDateTime; code that switched to streaming for a large result broke. Streamed rows now get the same coercion; verified identical toquery_sql/3on InfluxDB 3.Client.LocalrefusedGROUP BYandORDER BYreferences DataFusion accepts.GROUP BY bucket(a select alias — the usual form afterDATE_BIN(...) AS bucket),GROUP BY 1, 2,ORDER BY 2 DESCwere all "No field named" schema errors, andGROUP BY DATE_BIN(...), host— one row per bucket per host — was refused although the guide said it was supported. Positions and aliases are now resolved to the select items they name,DATE_BINcombines with grouping columns, and a position outside the select list is the engine's planning error; 14 of 14 comparison queries match InfluxDB 3.Client.Localskipped every Flux stage it did not understand. The double matched a few regexes anywhere in the query and ignored the rest, so|> mean(),last(),limit(),count(),aggregateWindow(),pivot()andgroup()all returned the raw rows;orbehaved likeand;!=,not,r._value > 2.0andrange(stop:)were ignored; a query withoutrange()or on a missing bucket returned rows or[]. Verified against InfluxDB 2.7, the newClient.Local.Fluxruns the pipeline in order —range,filterwith a real expression grammar and three-valued logic,first/last/min/max,mean/sum/count,limit,yield— numbers tables in the engine's series order, adds_start/_stop, and answers 24 of 24 comparison queries identically. Any other stage is a 400 naming it; norange()is the engine's unbounded-read 400; a missing bucket is its 404;mean()over strings is its 400. Two tests that asserted the old behaviour (all rows withoutrange(),[]for a missing bucket) now assert the engine's answers.Client.Localanswered InfluxQLSELECTas SQL. Verified against InfluxDB 3 over 50 statements, 28 of which differed: rows lacked"iox::measurement", a projection losttime,MEAN/COUNTwere refused, aggregates were not namedmean/count/sum, a lone selector did not return its point's time,LIMITdid not apply perGROUP BYseries, an unknown column or measurement was an error instead of{:ok, []},SHOW FIELD KEYSand bareSHOW TAG KEYSwere unsupported, andSHOW DATABASESlacked"deleted". The newClient.Local.InfluxQLmodule answers all 50 identically;GROUP BY time(...), regular expressions,fill()and other constructs it does not model are refused by name.Client.Localcomparedtimeat microsecond precision in SQL.ORDER BY timesorted the projectedDateTime, so points less than a microsecond apart came back in insertion order, and a literal such as'…:20.0000002Z'lost its last three digits. The engine uses the nanoseconds (verified); so does the double now, on everyORDER BY timeform (*, a projection, an alias oftime) and in every time literal.transport: :flightreturned null columns asnilkeys. Over HTTP, InfluxDB 3 leaves a null column out of the row, and the library documents that a null column is absent, notnil. The Flight reader put every schema column in every row, so the same query gave different maps per transport, and code matching onMap.has_key?/2or comparing rows broke when switching. Verified on 25,000 rows mixing tags, integer, unsigned, float, string and boolean fields and nulls across several record batches: the rows differed only innilkeys, and now they are identical. The reader's null tests assertedrow["v"] == nil, which passes whether the key is absent ornil; they now assert the key is absent.
[0.1.30] - 2026-09-23
Changed
Flight.Clientbuilds itsDoGetticket throughbuild_ticket/2instead of a second inline copy of the JSON. Admin tests assert what a call does (the bucket is listed with its rule, the database is gone) rather than:okalone.Connection.get/1reads:persistent_termwith a default instead of rescuingArgumentError.Writertests assert what a write does — every point of a gzipped payload is stored,precision:changes the stored time,:clientselects the client — instead of{:ok, :written}alone.ResponseParsertests every string cell for InfluxDB 3's zone-less timestamp shape before deciding whether to decode it; a one-clause binary pattern now screens out strings that cannot match before the regex runs (about 14 ns instead of 340 ns per ordinary string cell). Results are unchanged.BatchWritertests cover a chain that exhausts more than one retry (errors are counted per chain, not per attempt) andmax_retries: 0against a transport error.
Fixed
Admin.DatabasesandAdmin.Bucketsdocumented options that did not exist. The docs named:retention_periodand:retention_seconds;Client.HTTPreads:retention, so a consumer following the docs got the option silently ignored. The docs now name:retentionand its verified format: a duration string such as"30d"for InfluxDB 3 (an integer is a 400), seconds for InfluxDB 2 (1–3599 is a 500retention policy duration must be at least 1h0m0s;0is no expiry).Client.Local.delete_bucket/2returned:okfor a missing bucket, claiming to match "the idempotent delete semantics of the v2 API". InfluxDB 2 answers 404 (verified) andClient.HTTPreports it as{:error, %{status: 404, body: "bucket not found: <name>"}}; the double now does the same.create_bucket/3keeps:retention, refuses 1–3599 seconds as the engine does, andlist_buckets/1lists each bucket's"retentionRules"in the engine's shape.delete_database/2's 404 body is the engine'sthe requested resource was not found: <name>, and a v2 write to a missing bucket answers the engine's JSON 404 (bucket "<name>" not found) instead of a plain "database not found".Client.Localkept duplicate points as separate rows. InfluxDB 3 and 2.7 both treat a measurement's points with the same tag set and timestamp as one point — fields merge, the later write wins per field, the last of two such lines in a payload wins (verified) — and the double returned one row per write, so a fixture that rewrote a point saw two rows and doubled its aggregates. Points are still stored as written, one ETS insert each, and are merged on every read;DELETEremoves the merged point and counts it once.Client.Localcrashed on precision spellings the engine accepts.HTTP.write/3passesprecision:to InfluxDB 3 verbatim, which takesns | n | nanosecond | us | u | microsecond | ms | millisecond | s | second | auto(verified), and maps the long names onto InfluxDB 2'sns | us | ms | s. The double accepted only the four long atoms and raisedFunctionClauseErroron:ms,"ms","nanosecond"or:auto, so a write that works in production crashed in tests. It now accepts what each profile's server accepts, implementsautoat the engine's thresholds (|ts| below 5e9 seconds, 5e12 milliseconds, 5e15 microseconds, else nanoseconds; verified), and answers an unknown precision with the server's 400 body (serde error: unknown variant …on v3,invalid precision; valid precision units are ns, us, ms, and son v2).ConnectionSupervisorstarted a Finch pool the connection never used. With:finch_namepointing at an existing pool, a second idle pool was still started per connection, and the batch writer restarted with it underrest_for_one. No per-connection pool is started when:finch_nameis set.Connection.fetch!/1raised:persistent_term's bareArgumentErrorfor an unknown name; it now names the missing connection and says how to register one.- The manual telemetry emitters
write_stop/2,write_exception/2,query_stop/2andquery_exception/2emitted%{duration}only, while the spans (and the documented events) carrymonotonic_timetoo. They now emit the same measurements.write_stop/2's docs mentioned acompressed_bytesmetadata key that nothing emits (removed from the event docs on 2026-09-11); the leftover is gone. LineProtocol.encode/1emitted lines no server accepts. An empty tag value (host=), an empty tag key or field key, the reserved tag keytime, and a newline in a measurement, tag key, tag value or field key all encoded without complaint and failed at the server — and a newline splits the line, sotags: %{"host" => "a\nb"}stored a bogus measurementbon InfluxDB 3 (verified). A non-string tag value or an unsupported field value (nil, an atom) crashed the encoder with aFunctionClauseError. Each is now a tagged error fromencode/1({:invalid_tag_value, key, value},{:reserved_tag_key, "time"},{:invalid_field_value, key, value}, …); see "Validation" in the moduledoc.Client.Localworded atimefield on a new table as a column-type conflict. InfluxDB 3 says'time' is a reserved columnfor a tag or a field on a table that does not exist yet, and reports the column-type conflict withiox::column_type::timestamponly on an existing table. The double now does the same (the check moved from the parser into the store, which knows whether the table exists).
[0.1.29] - 2026-09-22
Fixed
Client.Local's:v2profile applied InfluxDB 3's write rules. Verified against InfluxDB 2.7, which differs on nearly every point: a field type conflict is HTTP 422 ("unprocessable entity", message ending indropped=N) with the other lines stored; a line that fails to parse rejects the whole payload with HTTP 400 ("code":"invalid",unable to parse '<line>': ...) and nothing is stored;timeas a field is dropped silently and as a tag is a 400; a tag and a field may share a name; an empty payload is accepted. The double now applies those rules under:v2and InfluxDB 3's under:v3_core/:v3_enterprise.
[0.1.28] - 2026-09-22
Fixed
Client.LocalrefusedLIMIT n OFFSET m, which InfluxDB 3 runs (#21). Verified against the engine:OFFSETskips rows beforeLIMITtakes them, in either order, on plain, projected, grouped andDISTINCTrows;OFFSET 0is a no-op, an offset past the end is an empty result, a negative offset is "OFFSET must be >=0" and a bare word is a schema error. All of that is now mirrored, so a paginated read can be tested against the double instead of re-implementing the offset in Elixir.
[0.1.27] - 2026-09-18
Fixed
Client.Localaccepted writes InfluxDB 3 rejects, and rejected one it accepts. Verified against the engine: a field written as an integer and later as a float (or tag then field, string then float, boolean then integer) is refused line by line with "invalid column type for column 'v', expected iox::column_type::field::integer, got iox::column_type::field::float";timeas a tag or field, a key used as both tag and field on one line, an integer outside int64 and an empty payload are refused; a rejected line drops only itself — the other lines are stored and the response is the partial-write JSON with one entry per bad line. The double accepted all of those (and stored every line), and refused a newline inside a quoted string value, which the engine keeps. It now keeps a per-measurement column schema ({:column, database, measurement, column}, fixed atomically by the first writer), applies a payload line by line, returns the engine's body, and drops the schema with the data when the database is deleted. Unsigned integers (7u) are accepted.Client.Local.delete_database/2kept the deleted database's points, so a re-created database was not empty. The points and schema go with it.
[0.1.26] - 2026-09-17
Changed
- SQL execution split out of
Client.Local. The 900-line executor — CTEs, joins, theWHEREevaluator, aggregates, casts, ordering, schema checks — isInfluxElixir.Client.Local.SQLExecutor, pure over the points it is handed through a fetch function;Client.Localkeeps storage, profiles and the InfluxQL and Flux paths. Public behaviour is unchanged. Client.Localchecks a query's column references against the first row before scanning every row's columns; the scan now runs only when a name is missing there, which is also when the error message needs the full list. Per-query fixed cost at 10k points drops from about 20 ms to under 5 ms; results are unchanged.Client.Local.SQLParsercuts a SELECT into its parts in one place. Eight regexes each found "the table after FROM" for their own dispatcher (star, column list, aggregate, DISTINCT, the clause check, the alias stripper, two helpers);split_select/1now does it once and the dispatchers work from its parts. No behaviour change; 71 lines fewer.
Fixed
Client.Localread a bare word insideIN (...)as a string.host IN (a, b)compared against"a"and"b"; on the engine the items are column references (v IN (1, other)works,host IN (a, b)is a schema error). Items are now parsed like every other comparand.Client.Localrefused a constant in a select list.0.0 AS volume(the #17 candle query's placeholder volume) was "unsupported column expression" in an aggregate and a schema error in a projection; the engine returns the constant on every row. Supported with an alias in every query shape; an unaliased constant is refused with the reason.DELETE ... WHERE a OR bcrashedClient.Local(:v3_enterprise) with aFunctionClauseError: the delete path still folded predicates with the helper from beforeWHEREbecame a boolean expression. It now evaluates the same expression treeSELECTdoes.CAST(col AS INTEGER)inWHEREwas rejected by 0.1.24 (#20) — and silently matched nothing in 0.1.23. The report is right that 0.1.24 refuses the orderbook depth query withClient.Local: unsupported WHERE clause: CAST(level AS INTEGER). It was never a working query on the double: 0.1.23 readCAST(level AS INTEGER)as a column named that and returned no rows for it, so tests passing against 0.1.23 were passing on an empty result.CAST(and DataFusion'scol::TYPEshorthand) now works wherever an expression is allowed —WHERE,BETWEEN,LIKE, projections, aggregates, arithmetic andORDER BY— with the engine's semantics, verified against InfluxDB 3 Core:INTEGER/INT/BIGINT,DOUBLE/FLOAT,VARCHAR/STRING/TEXT; text converts only when the whole string is a number, a float truncates to an integer, a number renders to text. A cast that cannot be performed ('abc'toINTEGER,timetoINTEGER) makes InfluxDB 3 Core drop the connection mid-response, whichClient.HTTPreports as{:error, {:connection_error, %Mint.TransportError{ reason: :closed}}}; the double reports{:error, {:connection_error, :closed}}.ORDER BYignored every term after the first inClient.Local.ORDER BY symbol DESC, levelsorted bysymbolonly. All terms apply, each with its own direction, and a term may be an expression (ORDER BY CAST(level AS INTEGER) DESC) on raw and projected rows.
[0.1.25] - 2026-09-15
Fixed
Client.LocalignoredGROUP BYon a plain projection andORDER BYon column-grouped aggregates, and sampled a row for an ungrouped column.SELECT host FROM p GROUP BY hostreturned every row;SELECT host, SUM(v) AS t FROM p GROUP BY host ORDER BY t DESCcame back in map order;SELECT host, MAX(v) FROM p(noGROUP BY) picked the first row's host. The engine returns one row per group, honours the ordering, and fails planning for the ungrouped column ("must appear in the GROUP BY clause or must be part of an aggregate function"); the double now does all three.Client.Localanswered queries that name a column no row has.SELECT nosuch,MAX(nosuch),WHERE nosuch = 1,GROUP BY nosuch,ORDER BY nosuchandDISTINCT nosuchare all the same 500 schema error on InfluxDB 3 ("No field named nosuch"); the double returned rows without the column, no rows, or unsorted rows depending on the clause. Every column reference in a query is now checked against the rows' columns (the check added forWHEREexpressions in 0.1.24 generalised), and an output alias remains a validORDER BYtarget.
[0.1.24] - 2026-09-15
Fixed
- An unbound
$placeholdermatched nothing inClient.Local; the engine fails planning ("No value found for placeholder with name $host"). The double now returns that error, so a missing binding cannot pass a test as an empty result. Client.Localrefusedmedian()andCROSS JOIN, which InfluxDB 3 runs (#19). Verified against InfluxDB 3 Core:medianreturns the middle value, or for an even count the mean of the two middle values in the column's type (two integers average with integer division: the median of 1 and 4 is 2), null over no rows, and is rejected overtime;FROM w CROSS JOIN refpairs every row with every row ofref. Both are supported, so the median-screened candle query in the issue now runs on the double with the same rows as the server. A column present on both sides of the join is refused as ambiguous, as the engine refuses the unqualified reference.Client.Localcompared a bare word inWHEREas a string.price <= med * 3comparedpricewith the text"med * 3"andhost = prodwith"prod"— both silently wrong. Either side of a comparison may now be an arithmetic expression over columns, a bare word is a column reference, and a column no row has is the engine's schema error ("No field named prod"), which is what production returns for a forgotten pair of quotes.- A
nilparam rendered as the wordnilinClient.Local; it now renders asNULL, which never matches, as the JSONnullJason sends over HTTP never matches.
[0.1.23] - 2026-09-15
Fixed
Client.Localreturned wrong rows forOR,NOT, parentheses and<>inWHERE, and forLIMIT 0. The clause splitter only knewAND:v > 3 OR v < 2was read as one predicate against the string"3 OR v < 2",NOT host = 'a'andv <> 1.0matched nothing, andLIMIT 0returned every row where the engine returns none.WHEREis now parsed as a boolean expression —ANDbinding tighter thanOR,NOT, parentheses, string literals opaque — and<>,[NOT] BETWEEN ... AND ...(includingtime) and[NOT] LIKE/ILIKEare supported with the engine's semantics (LIKEcase-sensitive,_one character,LIKEover a numeric column reproduces the engine's planning error).LIMIT 0returns no rows; a negative or non-numericLIMITis rejected as the engine rejects it. A malformed expression is rejected, never truncated.Client.Localcompared a string tag against a bare number by Erlang term order, sorack > 3matched every tag. The engine keeps the column as text and renders the literal (rack = 2matches"2";rack > 3does not match"10"); the double now does the same.Flight.Readerwalked empty FlatBuffer vectors at bogus indices.for i <- 0..(count - 1)withcount == 0is the descending range[0, -1]in Elixir, so a schema with no fields or a record batch with an empty buffers vector was read twice at invalid positions instead of not at all. The ranges now carry an explicit//1step, as the other comprehensions in the module already did.Client.Localrefused projected arithmetic and CTEs InfluxDB 3 runs (#18). Verified against InfluxDB 3 Core:SELECT (bid + ask) / 2 AS mid, time FROM qandWITH w AS (SELECT bid, time FROM q) SELECT DATE_BIN(INTERVAL '1 minute', w.time) AS time, MAX(w.bid) AS hi FROM w GROUP BY DATE_BIN(INTERVAL '1 minute', w.time)both return rows on the server and wereClient.Local:400s. The double now supports an arithmetic expression as a projected column (with an alias;ORDER BYmay name it), non-recursiveWITHCTEs executed in order (a later CTE or the finalSELECTreads an earlier one), table aliases (FROM q AS w,FROM q w) andalias.columnqualifiers in every clause.Client.Localsilently ignored everything after the table name.SELECT * FROM w CROSS JOIN qanswered fromwalone,... UNION SELECT ...tookUNIONas a table alias, andWHERE x IN (SELECT ...)compared against the string"SELECT ...". Joins, set operations, subqueries,HAVING,OFFSETand window functions are now rejected by name (Client.Local: unsupported SQL construct JOIN). Keywords are matched by the shape only a clause can have (OFFSET 1,OVER () and string literals are ignored, so a column namedoffsetoroverand a value such as'select from join'— both fine on the engine — still work.Client.Localreturned"time" => nilfor a row without a timestamp (a CTE that did not projecttime); the column is omitted, as everywhere else.
[0.1.22] - 2026-09-14
Changed
BatchWritertests no longer inspect GenServer state to check that configuration was stored; scheduling and jitter are asserted through the observable flush instead.Client.Localsplit into three modules. The 2,470-line module now owns storage, capability checks and query execution (1,450 lines); the SQL parser isInfluxElixir.Client.Local.SQLParserand the line-protocol parser isInfluxElixir.Client.Local.LineProtocolParser, both pure. Public behaviour is unchanged; the contract suites prove it.Client.Local.query_influxql/3matches eachSHOWpattern once.- Removed the unused internal
InfluxElixir.InfluxCasecase template fromtest/support/(never shipped; no test used it). Client.Local's line-protocol splitters accumulate tokens in binaries (runtime-optimised append) instead of one list cell per byte plus a reverse and join, cutting allocations on every write to the double.
Fixed
Client.Localacceptedtimecomparands InfluxDB rejects, and silently matched nothing for ones it accepts. Verified against InfluxDB 3 Core: a bare integer (time > 1700000000) or integer param fails planning on the server ("Cannot infer common argument type for comparison operation Timestamp(ns) > Int64") and an unparseable string fails execution, while the double returned{:ok, []}for both;now() - INTERVAL '2 minutes'runs on the server but was compared as the literal string, so it too returned{:ok, []}. The parser now accepts exactly the engine's forms — quoted ISO-8601 datetimes (zoned, zone-less, fractional), quoted dates,now()offset byINTERVALterms, evaluated at query time — and rejects the rest with aClient.Local:400 that names the engine's rule.DateTimeparams rendered as~U[...]inClient.Local. Jason sends them as ISO-8601 strings over HTTP; the double now rendersDateTime,NaiveDateTimeandDateparams the same way.SELECT DISTINCT ... ORDER BYwas ignored byClient.Local: rows came back ascending whatever the direction.ORDER BYon a selected column is honoured; on any other column it is rejected with DataFusion's own message.MAX(time)/MIN(time)returned an empty row fromClient.Local(the timestamp is not a field, so the aggregate saw only nulls). They now return theDateTime, as the engine does;AVG(time),SUM(time), the statistics overtimeand arithmetic ontimeare rejected as DataFusion rejects them.Client.LocalrefusedCOUNT(DISTINCT col)andWHERE col IS [NOT] NULL, both ordinary SQL the engine runs. Both are supported.- HTTP JSON left aliased timestamp columns as strings — see the entry
above for #16/#17; this sweep's contract tests cover
MAX(time)too. BatchWriterdocumented a backpressure it could never apply. The buffer emptied on every flush, so{:error, :buffer_full}was unreachable and the tests that "proved" it forged the GenServer state. Automatic flushes now wait for an in-flight retry chain instead of opening a new chain per batch against a failing server, the buffer is bounded at10 * batch_sizewhile a chain is in flight, and the deferred buffer is flushed when the chain ends. Awrite_sync/3caller is answered by its own chain's result (the caller's reference travels with the chain; before, a later chain could answer it).BatchWritertests no longer inject:retrymessages or read GenServer state: retries are exercised through a closed port (unit) and through a Finch pool checkout timeout that resolves before the backoff fires (integration, against the real server).Client.Localrejected valid InfluxDB 3 SQL (#16, #17). Verified against InfluxDB 3 Core:STDDEV/STDDEV_SAMP/STDDEV_POP/VAR/VAR_SAMP/VAR_POP, arithmetic inside an aggregate (SUM(value * value),AVG(bid + ask), integer operands dividing as integers),selector_first|last|min|max(field, time)['value' | 'time'],SELECT DISTINCT a, b, andORDER BYa projected alias (ORDER BY bucket DESC) all work on the server and were allClient.Local:400s in the double. All are now supported with the values the engine returns;VARIANCEstays rejected because DataFusion has no such function.Client.Localreturnednilcolumns the real engine omits. InfluxDB 3 leaves a null column out of the JSON row entirely (an empty group carries onlyCOUNT: 0; a sample statistic over one row has no key). The double now omits them too, sorefute Map.has_key?(row, "avg")means the same thing on both.- HTTP JSON responses left timestamp columns other than
timeas strings. ADATE_BIN(...) AS bucketalias orselector_*(...)['time']came back as"2023-11-14T22:12:00"over HTTP but as aDateTimeover Flight and fromClient.Local.Query.ResponseParsernow decodes InfluxDB 3's zone-less timestamp rendering under any column name (zoned RFC3339 strings are still decoded only undertime/_time/_start/_stop). mix docswarned that the README'sLICENSElink had no target; the licence is now an ExDoc extra.
Added
InfluxElixir.Client.Local.check_sql/1— parse a query without running it and get the sameClient.Local:errorquery_sql/3would, so a test canflunk/1with the reason instead of being silently excluded.- Testing guide: "Checking a Query Before Running It" and "Running Against a
Real InfluxDB" (the integration tier,
INFLUX_V3_CORE_HOST/_PORT, Docker one-liners), and the new aggregate, selector and null-omission semantics with the values recorded from the engine. CLAUDE.mddescribed a/docslayout (architecture/,api/,development/, per-directory READMEs, a design template) that did not exist. The READMEs and template now exist,docs/design/README.mdindexes every design document and carries the real-engine Docker one-liners, andCLAUDE.mddescribes the actual layout.- The shipped usage rules made false claims. They told consumers to
start a Finch pool themselves (the supervisor starts one per connection),
that booleans encode as
t/f(they aretrue/false), to pass params as a keyword list (a map), and that write errors carry:retryable/:non_retryableatoms (no such atoms exist; errors are%{status, body}or{:connection_error, reason}). All three rule files rewritten against the current code. Client.HTTPraised on keyword-listparams:. Jason cannot encode the tuples, soparams: [tag: "v"]crashed the HTTP client whileClient.Localaccepted it — exactly the shape the old usage rules recommended. Both clients now accept a map or a keyword list; the contract suite proves it against the real engine.Client.Locallost concurrent writes to the same database (#15). Points were stored as one list per measurement and every write read the list, prepended and wrote it back, so parallel writers overwrote each other's inserts while all reported{:ok, :written}(159 of 480 survived in the report). The ETS layout is now one object per point, database, bucket and token, so every mutation is a single atomic insert or delete. The same change removes the quadratic copy on bulk writes: 20,000 lines took 63 s and now take well under a second. Points scan in insertion order.- The testing guide's "Key Differences" still listed
first/lastas supported aggregates and omittedDISTINCT,GROUP BY <columns>,COUNT(*)and$paramsubstitution; corrected to the current parser. - README usage example could not work. It placed
{InfluxElixir, ...}in a supervision tree (the facade has nochild_spec/1; the library is an OTP application configured viaconfig :influx_elixir, :connections), put a scheme inhost:and used the nonexistentdefault_database:key, which HTTP config validation now rejects at startup. Rewritten with a working configuration, a write/query example, the v2 options and the shipped test helper. InfluxElixir.TestHelpernow ships in the package. It was documented (CLAUDE.md, usage rules, the original design) as a helper for consuming applications' test suites, but lived undertest/support/, which is only compiled in this repository's test environment — no consumer ever received it. It is now underlib/. The usage rules also named a nonexistentsetup_local/1; the function issetup_influx/1, which passes its options straight toClient.Local.start/1. Covered by its own test module.InfluxElixir.Admin.Healthdocumented an atom-keyed%{status: "pass"}result; both clients return string keys (%{"status" => "pass"}), as the 2026-03-13 integration plan already required.
[0.1.21] - 2026-09-11
Added
- Telemetry is actually emitted.
InfluxElixir.Telemetrydocumented[:influx_elixir, :write | :query, ...]events, but nothing in the library called it.Write.Writer.write/3(henceInfluxElixir.write/3and everyBatchWriterflush) now emits the write span withdatabase,bytesandpoint_count;InfluxElixir.query_sql/3,execute_sql/3,query_influxql/3andquery_flux/3emit the query span withdatabase,transport(the client module) and, for list results,row_count.:stopmetadata carriesresult: :ok | :error. BatchWriterandWrite.Writeraccept a:clientoption to write with a specific client module instead of the configured one.InfluxElixir.Configknows:timeout,:batch_writerand:finch_name.
Fixed
Client.HTTPnever set Finch'spool_timeout(#14), so every request waited at most Finch's default 5 s to check a connection out of the pool no matter how generous:timeoutwas, and against a slow multi-node endpoint failed with a transport:timeoutat five seconds. A:pool_timeoutoption now resolves like:timeout(per-call opt → connection → 5_000) and is passed on every request, including the streaming query. Verified against InfluxDB 3 Core with a size-1 pool held by a sleeping stream — which also showed that Finch raises on a checkout timeout rather than returning an error, so the exception used to escapequery_sql/3. It is now{:error, {:connection_error, :pool_timeout}}, and the streaming query raisesInfluxElixir.StreamErrorwithreason: :pool_timeout.- Flight and HTTP now return the same
timevalues.Flight.Readerdecoded Timestamp columns to raw integers while the HTTP path yieldsDateTime; the reader now reads the ArrowTimeUnitand converts.Query.ResponseParseralso treated InfluxDB 3's zone-less JSON timestamps ("2023-11-14T22:13:20.123456789") as opaque strings becauseDateTime.from_iso8601/1rejects them; they are now parsed as UTC. Verified against InfluxDB 3 Core: identical rows on both transports. transport: :flightwas documented but ignored by the facade,Query.SQLand the usage rules; every query went over HTTP.Client.HTTP.query_sql/3now dispatches toFlight.Clientwhentransport: :flightis given, using the connection's host/token, the resolved database, andflight_port(opt, connection, or 443).params:are rejected over Flight instead of being dropped. Verified against InfluxDB 3 Core's Flight endpoint.BatchWriterretried 4xx responses. The discard clause matched{:error, {:http_error, status}}, a shape no client produces, so a rejected batch (bad line protocol, unknown database) was retried with backoff untilmax_retriesran out. It now matches the clients'%{status: 4xx}and drops the batch on the first response. The retry path is covered by a real transport error against a closed port.ConnectionSupervisorhanded the batch writer the raw config instead of the initialised connection, so abatch_writer:underClient.Localcrashed on its first flush. Covered by a supervisor-level test.ConnectionSupervisorvalidates HTTP connection config. A typo such asdefault_database:(which the facade and application docs themselves used) was silently ignored; withClient.HTTPit now fails at startup with aNimbleOptions.ValidationError.Client.Localconfigs are not validated.
Changed
timeis aDateTimeon every client and transport.Client.LocalreturnedtimeandDATE_BINbuckets as ISO 8601 strings, while the HTTP path (once its zone-less parsing was fixed, see below) and Flight returnDateTime. All three now returnDateTimewith microsecond precision, so the contract suite asserts one instant across Local, HTTP and Flight. Code that compared Local'stimeto a string must use aDateTime(six-digit sigil orDateTime.compare/2).InfluxElixir.write/3goes throughWrite.Writer, so payloads over 1 KB are gzipped likeBatchWriterflushes already were.Flight.Readerdecodes fixed-width columns with binary comprehensions (one pass, no per-element slicing) instead of indexedbinary_part/3.Client.Local.query_flux/3returns the long row shape real Flux returns: one row per field with_field/_value,_measurement,_time(aDateTime), the tags,resultand a per-seriestableindex, ordered by table then time.filter(fn: (r) => r._field == "...")is honoured. The old wide rows (%{"_measurement", "<field>" => v, "time"}) could not exercise consumer Flux handling; verified against InfluxDB 2.7.Client.HTTP.query_flux/3requests#datatypeannotations so CSV cells come back typed (double,long,unsignedLong,boolean, RFC3339 →DateTime) instead of as strings.Query.ResponseParser.coerce_types/1also converts_time,_startand_stop.parse/2returns{:error, {:unexpected_json, term}}for a JSON scalar body instead of raisingCaseClauseError.
Added
api_version: :v2 | :v3connection option (InfluxElixir.Config). Required for InfluxDB 2.x: a v2 server answers200to the v3 write path without storing anything, so writes silently vanished and malformed line protocol or an unknown bucket reported success. With:v2the client usesPOST /api/v2/write?org=&bucket=&precision=ns|us|ms|s.
Fixed
Client.HTTP.create_bucket/3works against real InfluxDB v2. It sent"orgID": "", which v2 rejects (id must have a length of 16 bytes). The org ID is now resolved from the connection's:orgname (org_id:overrides). Creating a bucket that already exists is treated as success, matchingClient.Local.Client.HTTP.delete_bucket/2accepts a bucket name. v2 deletes by ID; the name is resolved viaGET /api/v2/buckets?name=, so the same call works againstClient.Local. A 16-hex argument is used as an ID directly.- Flux CSV parsing uses NimbleCSV. The hand-rolled splitter left
\ron every last cell and header, turned the blank line between tables into a row and the next table's header into data, and broke quoted cells containing commas. All were observed against InfluxDB 2.7. LineProtocolfloats no longer lose precision.{:decimals, 17}formatting wrote1.0e-20as0.0; the shortest round-trip form is used (1.0e-20,2.5e-7), which InfluxDB 3 Core accepts and reads back exactly.Telemetry.write_start/1andquery_start/1emit wall-clocksystem_time. They emittedSystem.monotonic_time/0under that key, an arbitrary offset that is useless as a timestamp. Amonotonic_timemeasurement is emitted alongside, matching:telemetry.span/3.Flight.Client.query/3closes the gRPC channel whenDoGetfails; it was only disconnected on success.Client.Local.stop/1no longer races the owner process's ETS cleanup. Called from anon_exitafter the test process had exited, the:ets.info/1guard could pass and:ets.delete/1then raiseArgumentError, failing the test intermittently.Flight.Readerrow assembly is linear in the batch's row count. Cells were read withEnum.at/2on the column lists for every row, which made decoding a record batch quadratic; columns are now tuples read withelem/2.
Changed
Client.HTTProutes every request through onerequest/7helper that maps the status to{:ok, response}/{:error, %{status, body}}/{:error, {:connection_error, reason}}, replacing fourteen copies of the same three-clausecase. No behavioural change.
[0.1.20] - 2026-09-10
Changed
Client.Localordered aggregates now use the InfluxDB v3 SQL spelling (#13).first_value(field ORDER BY col [ASC|DESC])andlast_value(field ORDER BY col [ASC|DESC])are parsed and executed, includingGROUP BY <columns>for "latest value per group" queries. The InfluxQL-styleFIRST(field, time)/LAST(field, time)the double previously accepted are rejected: InfluxDB v3 fails planning on them (Invalid function 'last'), so accepting them let a query pass tests and 400 in production. The rejection names the v3 spelling.first_value/last_valuewithout an innerORDER BYare also rejected — DataFusion returns an arbitrary group member in that case, which the double cannot reproduce. Verified against a live InfluxDB 3 Core; the shared contract suite now passes against the real engine (it previously failed on the twoFIRST/LASTtests).Client.Localparser rejections are prefixedClient.Local:so anunsupported column expressionerror reads as a limitation of the test double rather than of InfluxDB. Plain aggregates (AVG,SUM,COUNT,MIN,MAX) now reject a second argument, as the real engine does.Client.Localreports a missing table the way the real engine does.query_sql/3on an unknown measurement returned{:error, {:table_not_found, name}}whileClient.HTTPreturns{:error, %{status: 400, body: "Error during planning: table ... not found"}}, and the streaming path mapped it to a 404. Both now produce the 400 planning error, so consumer code that matches%{status: 400}can be exercised against the double. Code matching the old tuple must be updated.
Fixed
BatchWriternow honours its:databaseoption. The value was stored in state and never forwarded to the write, so every flush landed in the connection's default database. It is now the write target unless:write_optsnames a:databaseexplicitly.Client.Local(:v2profile) accepts writes to buckets created withcreate_bucket/3. Writes only checked thedatabases:seeded at start, so a bucket created through the API returned404 database not found.Client.Localno longer re-types quoted string literals (#12). A bound string param or quoted literal such as'08338636'was parsed back throughInteger.parse, dropping the leading zero and changing the type, soWHERE repcode = $rc/IN ($rc)over zero-padded identifiers never matched while real InfluxDB v3 matched correctly. Quoted literals are now strings; only bare literals are typed. Comparing a string literal against a numeric field compares the field's text rendering, which is what DataFusion does (amount >= '1000.00'is lexical and matches500.0on the real engine too), so that footgun now fails in tests the same way it fails in production.- Linear-time accumulation in
Client.LocalWHERE parsing andFlight.Readerbatch decoding. Both appended with++inside a reduce, which is quadratic in the number of clauses / record batches. Client.Localparam substitution is whole-placeholder and single-pass.$hwas previously replaced inside$hmin, and a substituted string value containing another placeholder's name could be re-substituted.
[0.1.19] - 2026-07-08
Fixed
InfluxElixir.Client.Local.query_sql_stream/3now mirrors the HTTP client's error semantics (#11).Client.Localis the documented drop-in test double forClient.HTTP, but it still returned an empty stream on a query error or an unsupported operation whileClient.HTTPraised — so consumer code that rescuesInfluxElixir.StreamError(to avoid treating an outage as "no data") could not be exercised against the test double. It now raisesInfluxElixir.StreamErroron enumeration for both cases, matchingClient.HTTP.
Added
- Tests covering the Local
:http_status/:unsupportedstream-error paths and lazy (deferred) raising.
[0.1.18] - 2026-07-08
Fixed
query_sql_stream/3(HTTP transport) now truly streams and no longer swallows errors (#10). Previously it usedFinch.request/3, which buffered the entire response body and eagerly decoded every JSONL line before yielding — giving zero memory benefit overquery_sql/3— and it halted to an empty list on non-2xx statuses, transport errors, and unresolved databases, so every failure class looked like "zero rows". It now consumes the response withFinch.stream/5, decoding JSONL line-by-line with back-pressure (constant memory), and raises anInfluxElixir.StreamErroron a missing database, a non-success HTTP status, or a transport error when the stream is enumerated.
Added
InfluxElixir.StreamErrorexception, raised while consuming a streaming query that cannot produce rows. Carries a:kind(:no_database | :http_status | :transport | :decode | :unsupported) plus:status/:body/:reasoncontext.InfluxElixir.StreamError.stream/1builds anEnumerable.t()that defers the raise to enumeration, shared by both client implementations.- Tests covering the HTTP
:no_database/:transportpaths (real Finch pool, no mocking) andStreamErrormessage construction.
[0.1.17] - 2026-06-30
Changed
- Loosened
decimalconstraint to~> 2.0 or ~> 3.0(#9). Unblocks downstream apps from upgrading pastdecimal 2.4.1(EEF-CVE-2026-32686) and from picking upecto ~> 3.14 → ash ~> 3.29chains. Surface used (Decimal.to_string/2,%Decimal{}pattern) is stable across 2 → 3. - Loosened
grpcconstraint to~> 0.11 or ~> 1.0(#9). Unblocks downstream apps from upgrading pastgrpc 0.11.5(5 CVEs including EEF-CVE-2026-48853). - Defaulted the Flight client to the Mint gRPC adapter so the library doesn't
pull in
:gun, which becameoptionalingrpc 1.0. Mint is already available viafinch. InfluxElixir.Supervisornow skips addingGRPC.Client.Supervisoras a child whengrpc 1.0+is present (1.0 auto-starts it via its ownApplication).
Fixed
LocalClientnow supportsCOUNT(*)as a scalar and DATE_BIN-bucketed aggregate.LocalClientWHERE-clause parser now returns a 400 error for unrecognised clauses (e.g.LIKE) instead of silently matching all rows.
Added
- Regression tests for
COUNT(*), explicit column-listSELECT,INoperator narrowing, write-timestamp preservation, and silent WHERE drop.
Earlier releases
- Initial project setup with module stubs
- CI pipeline with quality checks and auto-publish to Hex.pm