Concepts
The ideas the API builds on. TamarackDB follows the DCB specification: a command reads the events it needs, decides, and writes new events, and the write fails if the events it read have changed.
Events
An event is a fact the application recorded. Once written, it never changes and is never removed.
| Field | Set by | What it is |
|---|---|---|
type | the client | A non-empty string naming what happened, such as user-renamed |
identifiers | the client | Tags that name things in the domain: userId, courseId |
metadata | the client | Tags for everything else: author, correlation, tenant |
payload | the client | A string. The server stores it and never interprets it |
sequence | the server | The event’s Sequence Position: its place in the log |
time | the server | When the server received the write that carries it |
- A tag is a name and a value. On the wire, each set of tags is an object whose values are a string or an array of
strings:
{ "courseId": ["foo", "bar"], "otherId": "baz" }carries three tags. identifiersandmetadataare separate: a query for auserIdidentifier doesn’t match auserIdmetadata entry.- An event carries at most 20 identifiers and 20 metadata entries, with no duplicate. An empty array as a value is refused: leave the key out instead.
payloadcan be empty (""), but never missing ornull. Its format is up to the application.sequencestarts at 1 and grows by one with each event, with no gap. It’s the only order to rely on.timeis in UTC, with six digits after the second:2026-09-01T14:23:05.123456Z. Every event of one write shares it. Don’t order events bytime, and don’t base a decision on it: the clock can go back.- An event is at most
maxEventSizebytes, counting itstype, its tags, and itspayload. Store large content elsewhere and reference it.
Store ID
The store ID is a UUID that names the history a database holds. It’s drawn when the database is created, and changes
only with POST /reset, in development mode. A backup has its own store ID.
- Responses that depend on the store carry it in the
X-Tamarackdb-Storeheader. - A Sequence Position only means something next to its store ID. A client that keeps a position keeps both.
- If a read returns another store ID, the position no longer means anything: start over from the beginning.
Queries
A query selects events by type and tags. It’s "all", "none", or a list of items:
[
{
"types": ["user-created", "user-updated"],
"identifiers": [
{"name": "userId", "value": "123"}
]
},
{
"types": ["some-other-event"]
}
]- An event matches the query if it matches any item.
- Within an item, the event’s type is one of
types, and the event carries every listed identifier and metadata entry. - Values are compared exactly, byte for byte.
- The full grammar is in the HTTP API.
Append Condition
An Append Condition makes a write fail if an event relevant to the decision was appended since the read.
- A command reads the events its decision needs.
- It decides which events to append.
- It writes them. At commit, the server checks that no event matching the read was appended since.
- If one was, the commit gets
409 ConcurrencyExceptionand nothing is written. The command runs again: it reads again, and decides again.
- A read never locks anything.
- The client never builds a condition: in a transaction, the server makes one from each read.
- A read that finds nothing still protects its decision. A check that “no user has this email” fails at commit if a matching event was appended in between.
- Only what a condition covers is protected. A condition too narrow leaves a race that no error reports.
Transactions
A transaction groups what one command reads and writes. It lives in the server’s memory, locks nothing, and writes everything at commit, or nothing.
- Begin. The server returns a transaction ID.
- Decide. Each decision reads events, then writes its events, or an empty list.
- Projections. Between two decisions, the command reads and writes projections.
- Commit. The server checks every condition and every projection, then writes everything at once.
- A read of events opens a condition. The next call must be the write of events that closes it, even with an empty list. The decision to write nothing is checked at commit too.
- A decision that rests on no event reads
"none"first. - To read events without making a decision, use
QUERY /events, outside the transaction. - A read in a transaction sees committed events, then the transaction’s own pending events. A pending event has no
sequenceuntil the commit. - After a
409, run the whole command again, in a new transaction. - A transaction ends at its commit, when it’s abandoned, at its first error, after
txIdleTimeoutwithout a call, or when the server stops. Then every call on it gets404 TransactionNotFound. - One call at a time on a transaction. A second call sent at the same time gets
409 TransactionBusy.
Projections
A projection is state the application computes from events, stored next to them. Using projections is optional.
- A projection is a string payload, identified by
typeandid. It has no history: only its current state is kept. - It’s read by
typeandid. There is no query over projections. - Every write gives a projection a new version. A write changes a projection with
create(it must not exist),replace, ordelete(it must still be at the version read). Otherwise the write gets409. - In a transaction, the server tracks the versions. Outside one, with
POST /projections, the client sends them. - A version is opaque: compare it for equality only.
- A projection computed in a transaction may use the
timeof its events, never theirsequence.
Rebuilds
Every projection can be rebuilt from events. Backups leave projections out for that reason.
- Optionally, pause transactions to rebuild up to a fixed point.
- Delete the projections with a bulk delete.
- Read the events with
QUERY /events, page by page. - Write the projections with
POST /projections, in one write or several. - Resume.