Skip to content

feat!: Use the v2 API of EventSourcingDB whenever the server offers it, and follow the v2 API in the API of the SDK. - #22

Merged
goloroden merged 18 commits into
mainfrom
support-the-v2-api
Oct 19, 2026
Merged

goloroden merged 18 commits into
mainfrom
support-the-v2-api

Conversation

@goloroden

Copy link
Copy Markdown
Member

This release lets the client talk to EventSourcingDB 1.3 through its v2 API, while it keeps working with 1.2.0 through the v1 API, and it follows the v2 API in the public API of the SDK.

  • Choosing the API. Creating a client still sends no request. The first request asks GET /api/v2/system/ping: the ping event means v2, a 404 or a 405 means v1, whichever server sends it, and the answer is kept. Any other outcome fails that request, and the next one asks again. Requests made at the same time share one probe, and each fails with its own action if the probe fails. TransportChooser is the one place that chooses, with V1Transport and V2Transport behind it, so that removing the v1 API later is mechanical. On v2, reads use QUERY (RFC 10008), which the README now asks proxies to let through.
  • Errors. Everything the server reports is a DbApiException with code, description, details, status, and retryAfter for a 429. ErrorCode holds a constant for every code the server lists, checked against a copy of that list. For the texts of 1.2.0 that clearly match an error of the v2 API, the v1 path sets the code of the v2 API.
  • Writes. Writing events and registering an event schema throw an OutcomeUnknownException, wrapping the cause, whenever the write may have been stored, by the same rule as every other SDK.
  • Streams. Every stall is a ConnectionStalledException. A finite v2 stream without its end item fails, and so does observing that the server ends without an error, on both APIs.
  • README. It describes what changed, and how to upgrade from version 1, with every breaking change before and after.

The tests run every operation against a fake server for each API, so that both transports are fully covered under either image, and a container test checks that the client chooses the API the database offers.

BREAKING CHANGE: DbApiException.statusCode() is now status(), and reason() is now description(); a v2 server reports the errors of an accepted request with status 200 and a code, and errors within a stream are DbApiExceptions. HeartbeatTimeoutException is removed in favor of ConnectionStalledException. A write that fails in a way that leaves its outcome open, an interrupt after sending included, throws an OutcomeUnknownException instead of the failure itself. EventType.eventType() is now type(), and isPhantom() is replaced by its opposite, hasEvents(). readSubjects(baseSubject) is now readSubjects(subject, new ReadSubjectsOptions(recursive)). Observing that the server ends without an error now throws an EventSourcingDbException. A proxy in front of the database has to let QUERY through.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH

goloroden and others added 11 commits October 18, 2026 23:49
… its code, description, details, status, and retryAfter.

DbApiException now carries the code of the error, the URL that the server
sends, such as https://docs.eventsourcingdb.io/errors/precondition-violated,
along with its description, its details, its HTTP status, and, for a 429,
how long the server asks to wait. ErrorCode holds a constant for every code
the server lists, and a test checks them against a copy of that list.

The v1 API reports every error with a status code and a text. For the texts
of EventSourcingDB 1.2.0 that clearly match an error of the v2 API, the
client sets the code of the v2 API, e.g. precondition-violated for a write
that is refused with "state conflict: precondition failed". An error that
the server reports within a stream is a DbApiException with status 200 now,
instead of a plain EventSourcingDbException.

BREAKING CHANGE: DbApiException.statusCode() is now status(), and reason()
is now description(). Errors within a stream are DbApiExceptions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…ve HeartbeatTimeoutException.

A stream that receives nothing for the stall timeout, not even a heartbeat,
now ends with a ConnectionStalledException, like every other request that
stalls, also when observing events or running an EventQL query. The tests
that kept a stream alive with heartbeats now cover every stream.

BREAKING CHANGE: HeartbeatTimeoutException is removed. Catch
ConnectionStalledException instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…OutcomeUnknownException.

The server never stops a write halfway, so a write whose answer is lost may
have been stored all the same. Writing events and registering an event
schema now end with an OutcomeUnknownException, which wraps the failure,
whenever that is the case: after a stall, a broken connection, an abort
after the request was sent, a 500, a 502 or a 504, or an answer that is not
from an EventSourcingDB. A write is reported as certainly not stored only if
the client could not connect at all, or if the server refused it, which the
v1 API does with a 4xx, a 503, or a 507.

BREAKING CHANGE: A write that is interrupted after it was sent, or that
fails in another way that leaves its outcome open, now throws an
OutcomeUnknownException instead of the failure itself, which is its cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…vents with hasEvents.

EventType follows the v2 API now: its name is type, and hasEvents, the
opposite of isPhantom, tells whether events of the type were written. The
v1 API still sends isPhantom, which the client turns around.

BREAKING CHANGE: EventType.eventType() is now type(), and isPhantom() is
replaced by hasEvents(), which is its opposite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…PI does.

readSubjects takes the subject and ReadSubjectsOptions, whose recursive
tells whether to read the subject alone or the subject with every subject
nested in it. The v1 API always reads recursively, so the client keeps the
subject alone from what it reads, if recursive is false.

BREAKING CHANGE: readSubjects(baseSubject) is now readSubjects(subject,
options). Pass new ReadSubjectsOptions(true) for what readSubjects did
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…herwise.

The first request of a client asks the server whether it offers the v2
API, with GET /api/v2/system/ping. An answer with the ping event means v2,
a 404 or a 405 means v1, as EventSourcingDB 1.2.0 answers every path of the
v2 API it does not know. The client keeps the answer. Any other outcome
fails the request that asked, each request that waited for the same probe
with its own action, and the next request asks again. Creating a client
still sends no request.

TransportChooser is the one place that chooses, and V1Transport and
V2Transport stand behind it, so that removing the v1 API later means
removing one transport. Requests holds what both share: sending, the stall
timeout, and what closing the client ends. On v2, reads use QUERY, every
answer comes in an envelope, writes are answered with a stream that keeps
them alive with heartbeats, and a finite stream that ends without its end
item fails. A write that the v2 API refuses with an error in its answer, or
with 400, 401, 408, 413, 429, or 503, was certainly not stored; store-failed
and every other failure leave the outcome open.

Observing never ends on its own, so a stream of observed events that the
server ends without an error now fails with the same error as a finite
stream that ends before it is complete, on both APIs.

BREAKING CHANGE: A stream of observed events that the server ends without
an error now ends with an EventSourcingDbException instead of ending
quietly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…er server answers it.

A proxy in front of EventSourcingDB 1.2.0 that passes on the v1 API only
answers the probe itself, e.g. with a 404 from nginx. The probe took that
for an answer that is not from an EventSourcingDB, so every request failed,
although the previous release works behind such a proxy. A 404 or a 405
now means the v1 API, whichever server sends it. That costs no safety,
since every request to the v1 API checks the Server header anyway. Any
other answer from another server still fails, and is not kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…g. /a/b for /a/b/.

The v1 API reads subjects recursively only, so the client keeps the subject
itself from what it reads, and compares against the subject as the server
names it: a slash, followed by the subject without the slashes around it.
The comparison did that already; the tests now cover a nested subject with
a slash at its end and the root subject, against a fake server and against
both images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…ffers.

A database that offers the v2 API still answers the v1 API, so the other
tests pass whichever API the client chooses. The new test asks the database
on its own whether it answers GET /api/v2/system/ping, and checks that the
client chose the v2 API exactly then. It goes by the answer rather than by
the tag of the image, since once 1.3 is latest, latest offers the v2 API,
too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
The new section lists every change of version 2 that may require changes
to the code of an application, each with the code before and after, and
points to the sections that describe them in detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…nsport.

Everything that is particular to the v1 API now lives in V1Transport and
the classes it alone uses, so that removing the v1 API later means removing
those, along with the branch of TransportChooser that chooses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
@goloroden
goloroden requested a review from a team as a code owner October 18, 2026 22:43
@goloroden goloroden self-assigned this Oct 18, 2026
goloroden and others added 7 commits October 19, 2026 01:07
… or 405 means v1.

A proxy that passes on the v1 API only answers the probe with a page of its
own, e.g. an HTML page from nginx. The probe looks at the status code
before anything else, so it takes such an answer for the v1 API, too, and
the test now shows it with a 404 and a 405 that are HTML.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…s writes again.

With the system property jdk.httpclient.enableAllMethodRetry, the JDK
sends a POST again over a new connection, if the connection it was sent
over closed before the answer arrived. If that connection is refused, the
client took the write for one that was never sent, although the server may
have stored it already. The property is now read for each failed write,
and while it is set, a write that could not connect is reported as
unknown, too. The README says that the property can store a write twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…nds as unknown.

The README and the Javadoc said that an interrupt before the request is
sent ends a write with a CancellationException. That holds only before the
client starts to send it. Once it has, even while the connection is still
being established, the write ends with an OutcomeUnknownException, since
the client can not tell how far the request got. The texts now say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…ved, but before it had its body.

close() released what the stream held only the first time it was called.
If it came after the headers of the response had arrived, but before the
stream had its body, it found nothing to close, and the stream ended
without closing the body later on. With an HTTP client of the caller's
own, which the client does not shut down, the connection of an observation
then stayed open. close() now releases what the stream holds each time it
is called, and the stream calls it again once it notices that it is closed,
which it does with its next read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…t.properties, too, and keep a value that was true.

The JDK reads jdk.httpclient.enableAllMethodRetry once, with the first
request of any HTTP client, as a system property that falls back to
conf/net.properties of the JDK. The client read the system property only,
with each failed write, so it missed a value in net.properties, and took a
write that could not connect for one that was not stored once the property
had been cleared after the JDK had read it. WriteRetries now reads the
property as the JDK does, and keeps a value that was true once, since the
JDK may have read it while it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…e, as start documents.

A container whose database was not reachable through its mapped port,
which Docker Desktop sometimes does, failed to start at once instead of
being replaced. Its wait for pings timed out after ten seconds of
connections that the port proxy of Docker accepts and closes without an
answer ("Unexpected end of file from server"). start replaced a container
only if it was still running after the failure, but Testcontainers stops
a container whose wait fails before it reports the failure, so start
never replaced one. The wait for pings now notes whether the container was
running when it failed, and start goes by that. The tests no longer fail
a container that has started, but its wait, so that the failure takes the
path through Testcontainers that a real one takes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
…, and keep what net.properties holds before a malformed line.

The JDK reads jdk.httpclient.enableAllMethodRetry with the first request
of any HTTP client. The client read it only when a write failed, so it
missed a value that was cleared before. It now reads it when it is created,
too, before it sends a request, and keeps a value that was true. The README
says when the client sees the property.

The JDK goes on with the entries of a malformed conf/net.properties that
it read before the line that is malformed. The client failed on such a
file with an IllegalArgumentException instead. It now keeps those entries,
too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QykegdHe7vhHMBN3kWxgfH
@goloroden
goloroden merged commit c5e0a9f into main Oct 19, 2026
3 checks passed
@goloroden
goloroden deleted the support-the-v2-api branch October 19, 2026 03:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant