Skip to main content
Version: 0.13.0

EventStore API

The EventStore service owns event operations:

  • SaveEventsV2
  • SaveEvents (deprecated)
  • GetEvents
  • GetWriteContext
  • GetLatestByCriteria
  • CatchUpSubscribeToEvents
  • Ping
  • GetServerInfo
  • CreateIndex
  • DropIndex
  • ListIndexes
  • GetIndex

Connect and authenticate​

Every example on this page assumes an authenticated client connected to a running server. The default credentials are admin:changeit; change ORISUN_ADMIN_PASSWORD before exposing the server.

Pick your client once; every tabbed example below follows that choice across this page and the tutorial.

important

The orders and ledger boundaries used below must already exist and report BOUNDARY_LIFECYCLE_STATUS_ACTIVE. Create new boundaries—or import existing physical boundaries—through the Admin boundary API before using EventStore methods. Requests to unknown or not-yet-installed boundaries are rejected.

import (
"context"
"log"

orisun "github.com/oexza/orisun-client-go"
eventstore "github.com/oexza/orisun-client-go/eventstore"
)

client, err := orisun.New(
"localhost:5005",
orisun.WithCredentials("admin", "changeit"),
orisun.WithInsecure(), // plaintext transport; use WithTransportCredentials for TLS
)
if err != nil {
log.Fatal(err)
}
defer client.Close()

ctx := context.Background()

Data model​

Events have four caller-supplied fields:

FieldDescription
event_idStable event identifier. Use UUIDs for portability; the docs use UUIDv7 examples. PostgreSQL requires UUID format, while SQLite accepts any string. Orisun does not deduplicate writes by event_id; use it for application-level retry recognition and consumer deduplication.
event_typeEvent type name, for example OrderPlaced.
dataJSON object encoded as a string. Criteria queries match this JSON object.
metadataJSON object encoded as a string. Use for request source, tracing, or non-domain metadata.

Orisun also stores a durable position and date_created on committed events.

note

Storage backends expose the event envelope through reserved fields in the queryable document:

Document fieldEnvelope value
__eventIdEvent ID
__eventTypeEvent type
__commitPositionCommit position, as an integer
__preparePositionPrepare position, as an integer
__writeIdID of the write that committed the event
__dateCreatedCreation timestamp, in UTC RFC 3339 format
__metadataThe metadata JSON value

PostgreSQL and SQLite persist these values inside data. Their ordering and write-context columns are generated projections of that document, not separate writable values. PostgreSQL retains pg_xact_id as internal visibility bookkeeping. FoundationDB stores metadata and timestamps inside the document and derives positions from its native commit-ordered key. It derives write IDs from that key and the stored batch-end offset; no second transaction fills in positions.

On retrieval, the backend extracts the usual envelope and removes all top-level __* fields from returned application data. Nested fields and metadata values remain untouched. API and SDK event shapes stay unchanged. Content criteria and live subscription filters use reserved names, for example {"key":"__eventId","value":"your-event-id"}. Metadata is a JSON value under __metadata; this does not introduce dotted-path querying into nested objects.

FoundationDB uses its native event-key range for criteria containing __commitPosition or __writeId, with any remaining tags applied within that range. A __preparePosition criterion needs one of those anchors. These three fields cannot be secondary-index fields or conditions. Other FoundationDB criteria continue to require a ready covering index.

Top-level keys in application event data beginning with __ are reserved for Orisun, including names not currently in use. Writes containing such keys are rejected before storage (with INVALID_ARGUMENT over gRPC). This restriction applies only to the root of data: nested objects and the separate metadata field may contain keys beginning with __. The write-request event_type field (or SDK eventType property) supplies __eventType; application payloads must not set it themselves. An ordinary application key named eventType is preserved and is no longer the discriminator.

Upgrading stored event fields​

Follow the event-envelope upgrade guide for preparation, rollout, verification, failure recovery, and rollback steps.

Content queries and index definitions now use __eventType instead of eventType. The API event_type field and SDK eventType property keep their existing names. There is no query-time alias for the old JSON key.

Stop all Orisun servers sharing the storage before upgrading. At startup, Orisun migrates stored event discriminators, retained CCC observations, and index definitions created through the index API. PostgreSQL and SQLite rebuild affected managed indexes within their migration transaction. FoundationDB migrates in resumable batches before making the boundary available; its index entries retain the same values and positions. Existing positions, write IDs, and publisher checkpoints are preserved. The upgrade also moves stored event IDs into data.__eventId, removing the separate PostgreSQL/SQLite event_id column and FoundationDB record field. The remaining envelope migration replaces SQL columns with generated projections and rebuilds their indexes in the same transaction. FoundationDB migrates metadata, timestamps, and batch-end offsets in resumable batches, maintaining affected indexes. Historical events whose write context was never recorded continue to return an empty write ID. Large stores may take time to migrate.

Update application criteria, subscription filters, and index declarations to __eventType before resuming traffic. Re-read command contexts after the upgrade; in-flight observations using the old key must not be reused. Indexes created directly with SQL are not managed by Orisun and must be reviewed separately. Older server binaries cannot be used with the migrated storage; take a backup before upgrading if you need to be able to restore the old format.

If a legacy event already contains both eventType and __eventType, migration stops with a conflict instead of overwriting either value. Existing top-level __eventId values also block the event-ID migration, including JSON null. The remaining-envelope migration likewise rejects existing __commitPosition, __preparePosition, __writeId, __dateCreated, or __metadata fields; FoundationDB also reserves __writeLastOffset for its batch-end offset. Existing FoundationDB secondary indexes on commit-derived fields must be removed before that migration. Resolve any collision while the servers are stopped, then restart. Nested application fields and metadata are not renamed. Historical eventType was the store discriminator; after migration that unprefixed name is available for application data.

SaveEventsV2​

SaveEventsV2 atomically appends one or more events. Its consistency list contains the complete query and latest matching position for every context the command read. Orisun validates all observations in the write transaction before inserting any event.

Atomicity is scoped to the request's one boundary. Orisun does not provide a cross-boundary transaction; events that must commit together belong in the same boundary and the same request.

FieldRequiredMeaning
boundaryYesActive boundary receiving the complete event batch.
eventsYesOne or more events committed atomically.
consistencyNoQuery-level observations that must all still be current. Omit it for an unconditional append.

Each observation has one non-empty query and one position. Tags inside a criterion are AND predicates; criteria inside a query are OR alternatives. The position belongs to that whole OR query. It is never a position per criterion.

The command lifecycle is:

  1. Read each complete context needed for the decision.
  2. Preserve each exact query with that query's latest matching position.
  3. Apply domain validation in the command handler.
  4. Send the new events and all preserved observations in one SaveEventsV2 request.
  5. On ALREADY_EXISTS, re-read every context and make the decision again.

The reads may be separate. The save is the synchronization point: every observation is rechecked atomically with the append.

Preserve a GetLatestByCriteria read​

criteria := []*eventstore.Criterion{
{Tags: []*eventstore.Tag{{Key: "scopes.orderId", Value: "order-17"}}},
{Tags: []*eventstore.Tag{
{Key: "__eventType", Value: "CustomerOrderingSuspended"},
{Key: "customerId", Value: "customer-4"},
}},
}
latest, err := client.GetLatestByCriteria(ctx, &eventstore.GetLatestByCriteriaRequest{
Boundary: "orders",
Criteria: criteria,
})
if err != nil {
return err
}

_, err = client.SaveEventsV2(ctx, &eventstore.SaveEventsV2Request{
Boundary: "orders",
Events: []*eventstore.EventToSave{{
EventId: "018f2d5e-0002-7000-8000-000000000002",
EventType: "OrderConfirmed",
Data: `{"orderId":"order-17","customerId":"customer-4","scopes.orderId":"order-17"}`,
Metadata: `{}`,
}},
Consistency: []*eventstore.ConsistencyObservation{{
Query: &eventstore.Query{Criteria: criteria},
Position: latest.ContextPosition,
}},
})

GetLatestByCriteria remains unchanged: combine the exact criteria sent to it with its existing context_position to construct the V2 observation. One position belongs to the complete OR query, not to each criterion.

Preserve multiple independent reads​

For multiple independently read contexts, include multiple observations:

grpcurl -H "$AUTH" -d @ localhost:5005 orisun.EventStore/SaveEventsV2 <<EOF
{
"boundary": "orders",
"events": [{
"event_id": "018f2d5e-0002-7000-8000-000000000002",
"event_type": "OrderConfirmed",
"data": "{\"orderId\":\"order-17\",\"customerId\":\"customer-4\",\"scopes.orderId\":\"order-17\"}",
"metadata": "{}"
}],
"consistency": [
{
"query": {
"criteria": [
{"tags": [{"key": "scopes.orderId", "value": "order-17"}]},
{"tags": [
{"key": "__eventType", "value": "OrderCancelled"},
{"key": "orderId", "value": "order-17"}
]}
]
},
"position": {"commit_position": 12, "prepare_position": 8}
},
{
"query": {
"criteria": [
{"tags": [
{"key": "__eventType", "value": "CustomerOrderingSuspended"},
{"key": "customerId", "value": "customer-4"}
]},
{"tags": [
{"key": "__eventType", "value": "CustomerOrderingRestored"},
{"key": "customerId", "value": "customer-4"}
]}
]
},
"position": {"commit_position": 9, "prepare_position": 0}
}
]
}
EOF

Every observation is required to contain a non-empty query and a position. Use {-1, -1} for a query observed to have no matches. Duplicate equivalent observations are normalized, contradictory positions for the same query are rejected, and requests with excessive observation fan-out fail closed.

Batches are atomic. Events in one batch share the same commit position and receive increasing prepare positions. If any observation is stale, Orisun returns ALREADY_EXISTS and appends none of the events.

WriteResult.log_position is the position of the last event in the committed batch. It is a write receipt, not a general-purpose CCC token. Reuse it as an observation position only when you can prove that the same complete query was observed and the last event in the batch is its latest match. Normally, derive positions from GetLatestByCriteria or a complete GetEvents read.

Validation and limits​

The server rejects the entire request with INVALID_ARGUMENT before touching storage when any of these rules fail:

  • boundary is empty, events is empty, or event JSON is invalid;
  • an observation has no query or position;
  • a query has no criteria, a criterion has no tags, or a tag has no key;
  • a position is neither exactly {-1, -1} nor a pair of non-negative values;
  • one criterion repeats a key with different values; or
  • equivalent observations claim different positions.

Equivalent criteria, repeated identical tags, and duplicate observations with the same position are normalized rather than evaluated repeatedly. One request may contain at most 1,024 observations, 4,096 criteria across those observations, and 16,384 tags across those criteria. These bounds prevent an individual write from creating unbounded query fan-out.

PostgreSQL and SQLite can evaluate an unindexed equality query correctly by scanning. FoundationDB requires each criterion to select a native position range through __commitPosition or __writeId, or have a ready covering secondary index; otherwise it returns FAILED_PRECONDITION. See Indexing.

Unconditional append​

Omit consistency when the command did not read any event context. This is the correct shape for ingestion, replay into a new boundary, and other deliberately unconditional writes. Do not send an empty query or a position by itself.

result, err := client.SaveEventsV2(ctx, &eventstore.SaveEventsV2Request{
Boundary: "orders",
Events: []*eventstore.EventToSave{
{
EventId: "018f2d5e-0001-7000-8000-000000000001",
EventType: "OrderPlaced",
Data: `{"customer_id":"c-1","amount":45}`,
Metadata: `{"source":"checkout"}`,
},
},
})

// result.LogPosition.CommitPosition / PreparePosition

The response contains the position of the last committed event in the batch:

{
"log_position": {
"commit_position": 1,
"prepare_position": 0
}
}

Orisun stores the API event_type value in event data as the canonical __eventType JSON key and derives returned event types from that key. You do not need to duplicate it in your payload, and later queries or indexes can match __eventType with normal content criteria.

For event-scoped models, put queryable scope keys in data as normal JSON keys, for example scopes.coursePublishedId, and index them like any other field. See Event Scopes for the modeling pattern.

Batches are atomic. Events in one batch share the same commit position and receive increasing prepare positions.

Save with one observed query​

Put the exact query the command read and that query's latest matching position in one observation. Use the context_position returned by GetLatestByCriteria, or the latest matching position from a complete GetEvents read. A store-head position or the position returned by an unrelated save is not a substitute.

_, err := client.SaveEventsV2(ctx, &eventstore.SaveEventsV2Request{
Boundary: "orders",
Consistency: []*eventstore.ConsistencyObservation{{
Position: &eventstore.Position{CommitPosition: 12, PreparePosition: 8},
Query: &eventstore.Query{
Criteria: []*eventstore.Criterion{{
Tags: []*eventstore.Tag{{Key: "customer_id", Value: "c-1"}},
}},
},
}},
Events: []*eventstore.EventToSave{{
EventId: "018f2d5e-0002-7000-8000-000000000002",
EventType: "OrderConfirmed",
Data: `{"customer_id":"c-1","amount":45}`,
Metadata: `{}`,
}},
})

If the latest event matching the query is no longer at the observed position, Orisun returns ALREADY_EXISTS. Treat that as a CCC conflict: re-read every context used by the command, decide again, and retry only if the command is still valid.

SaveEvents (deprecated)​

SaveEvents and SaveQuery remain wire-compatible for existing clients, but new code should use SaveEventsV2. The server translates a legacy request with a non-empty subsetQuery into one V2 observation and executes the same save implementation.

Deprecated V1 fieldV2 replacement
SaveEventsRequest.boundarySaveEventsV2Request.boundary
SaveEventsRequest.eventsSaveEventsV2Request.events
query.subsetQueryconsistency[0].query
query.expected_positionconsistency[0].position
warning

In the compatibility API, expected_position without a non-empty subsetQuery does not protect anything and is translated as an unconditional append. Do not use an expected position as a stream revision. To assert that a query is still empty, send that query in a V2 observation with position {-1, -1}.

V1 can represent at most one observed query. If a command read more than one independent context, migrate it to V2 and preserve every complete query as a separate observation. GetLatestByCriteria itself is unchanged.

GetWriteContext​

Every accepted save records the complete consistency observations checked for that atomic batch. Each observation retains its full OR query and the position observed before the write. Queries use the server's normalized representation; redundant equivalent observations may be deduplicated.

WriteResult.write_id identifies the accepted save. Every event in that save carries the same Event.write_id, including events returned by latest-by-criteria reads and catch-up or live subscriptions. Treat the ID as an opaque string scoped to its boundary. Separate saves retain separate IDs even when group commit puts them in one database transaction.

Call GetWriteContext with:

{
"boundary": "orders",
"write_id": "<write_id from the save result or event>"
}

The response contains write_id and consistency, an array of the same ConsistencyObservation shape accepted by SaveEventsV2. An existing record with an empty array means the save was unconditional. The deprecated SaveEvents RPC also records its effective consistency observation.

Events saved before this feature have an empty write_id and no recorded context. Their conditions are unknown; the server does not label them unconditional or attempt to reconstruct historical queries. Missing records return NOT_FOUND; malformed IDs return INVALID_ARGUMENT. The boundary must be active.

The context is store-owned and commits atomically with its events. Rejected or rolled-back saves leave no context record. PostgreSQL and SQLite store one record per save alongside the event table. FoundationDB stores the context under the save's final versionstamp, splitting large contexts into values within the same transaction. Context storage counts toward FoundationDB's transaction-size budget. No application metadata fields are reserved or rewritten for this feature.

GetEvents​

Read from the beginning:

resp, err := client.GetEvents(ctx, &eventstore.GetEventsRequest{
Boundary: "orders",
FromPosition: &eventstore.Position{CommitPosition: 0, PreparePosition: 0},
Count: 100,
Direction: eventstore.Direction_ASC,
})

for _, event := range resp.Events {
_ = event // event.Position is durable ordering within the boundary
}

Read by criteria:

resp, err := client.GetEvents(ctx, &eventstore.GetEventsRequest{
Boundary: "orders",
Query: &eventstore.Query{
Criteria: []*eventstore.Criterion{{
Tags: []*eventstore.Tag{{Key: "customer_id", Value: "c-1"}},
}},
},
Count: 100,
Direction: eventstore.Direction_ASC,
})

Page from a position:

resp, err := client.GetEvents(ctx, &eventstore.GetEventsRequest{
Boundary: "orders",
FromPosition: &eventstore.Position{CommitPosition: 1000, PreparePosition: 42},
Count: 100,
Direction: eventstore.Direction_ASC,
})

GetEvents returns matching events with their committed position and creation time:

{
"events": [
{
"event_id": "018f2d5e-0001-7000-8000-000000000001",
"event_type": "OrderPlaced",
"data": "{\"customer_id\":\"c-1\",\"amount\":45}",
"metadata": "{\"source\":\"checkout\"}",
"position": {"commit_position": 1, "prepare_position": 0},
"date_created": "2026-05-30T12:00:00Z"
}
]
}

Event adds position and date_created to the fields supplied at write time. CatchUpSubscribeToEvents delivers the same event shape.

Paging through a boundary​

GetEvents returns one bounded page (count, server-capped at 10000). To walk the whole log or a criteria set, page forward:

  1. First call uses from_position {0, 0} to start at the beginning.
  2. Process the page, then take the position of the last event.
  3. Pass it as from_position on the next call.
  4. Stop when a page returns fewer events than count.

Keep the consumer idempotent and deduplicate by event_id rather than assuming exactly-once paging. The position model behind from_position and direction is described in Positions and Ordering.

Use GetEvents as a command context​

GetEvents does not return a separate context position. When a command truly needs the matching history, finish reading the complete queried context and retain the greatest event position returned. Pair that position with the exact same query in one SaveEventsV2 observation. If the query returned no events, use {-1, -1}.

Do not build an observation from a truncated page, a page that stopped before the newest match, or a boundary-wide read paired with a narrower query. For carried-state models that need only the newest match per criterion, prefer GetLatestByCriteria; it returns context_position directly from the same snapshot as its results.

GetLatestByCriteria​

GetLatestByCriteria returns the latest event matching each criterion, assembled by the server from one consistent read snapshot, plus a context_position. It is the command-side read for the carried-state pattern: store the resulting state on each event, then a command needs only the latest event per criterion rather than a history replay.

resp, err := client.GetLatestByCriteria(ctx, &eventstore.GetLatestByCriteriaRequest{
Boundary: "ledger",
Criteria: []*eventstore.Criterion{
{Tags: []*eventstore.Tag{
{Key: "__eventType", Value: "AccountOpened"},
{Key: "accountOpenedId", Value: "018f2d5e-2001-7000-8000-000000000001"},
}},
{Tags: []*eventstore.Tag{{Key: "scopes.accountOpenedId", Value: "018f2d5e-2001-7000-8000-000000000001"}}},
{Tags: []*eventstore.Tag{
{Key: "__eventType", Value: "AccountOpened"},
{Key: "accountOpenedId", Value: "018f2d5e-2002-7000-8000-000000000002"},
}},
{Tags: []*eventstore.Tag{{Key: "scopes.accountOpenedId", Value: "018f2d5e-2002-7000-8000-000000000002"}}},
},
})

// One result per criterion, in request order.
// Use the root event when no scoped movement exists yet.
// For SaveEventsV2, pair these exact request criteria with resp.ContextPosition.
for _, r := range resp.Results {
if r.Event != nil {
// r.Event.Data carries the latest snapshot for this criterion
}
}

The response carries one result per request criterion in order (event unset when nothing matches) and context_position, which is the max position observed in the same snapshot, or {-1, -1} when nothing matched.

For SaveEventsV2, construct one observation from the exact combined criteria sent to this RPC and the returned context_position. Multiple complete reads may each contribute their own query-level observation because V2 validates all of them atomically. See Command Context Consistency.

CatchUpSubscribeToEvents​

Catch-up subscriptions replay stored events, then switch to live JetStream delivery.

Only one active subscription may use the same boundary and subscriber-name pair. Orisun holds a renewable JetStream lease for the complete catch-up and live lifetime. Closing the stream releases it immediately; if the subscriber or server disappears before cleanup completes, another subscriber can reclaim the lease after its 15-second expiry. Reuse a subscriber name for failover of the same logical consumer, and use distinct names for consumers that should run concurrently.

handler := orisun.NewSimpleEventHandler().
WithOnEvent(func(event *eventstore.Event) error {
// persist side effects, then checkpoint event.Position
return nil
}).
WithOnError(func(err error) {
log.Printf("subscription stopped: %v", err)
})

sub, err := client.SubscribeToEvents(ctx, &eventstore.CatchUpSubscribeToEventStoreRequest{
Boundary: "orders",
SubscriberName: "order-projector",
AfterPosition: &eventstore.Position{CommitPosition: 0, PreparePosition: 0},
}, handler)
if err != nil {
return err
}
defer sub.Close()

Filtered subscription:

sub, err := client.SubscribeToEvents(ctx, &eventstore.CatchUpSubscribeToEventStoreRequest{
Boundary: "orders",
SubscriberName: "placed-orders",
AfterPosition: &eventstore.Position{CommitPosition: 0, PreparePosition: 0},
Query: &eventstore.Query{
Criteria: []*eventstore.Criterion{{
Tags: []*eventstore.Tag{{Key: "__eventType", Value: "OrderPlaced"}},
}},
},
}, handler)

Ping​

Ping is an authenticated liveness check that takes no arguments:

if err := client.Ping(ctx); err != nil {
return err
}

GetServerInfo​

GetServerInfo returns information about the node that handles the call. It is authenticated but does not require a particular role.

info, err := client.GetServerInfo(ctx)
if err != nil {
return err
}
log.Printf("node=%s version=%s backend=%s",
info.NodeId, info.Version, info.Backend)

The response contains:

FieldMeaning
versionOrisun release version embedded at build time. Local development builds report dev.
git_commitSource commit embedded at build time, or unknown.
build_timeBuild timestamp embedded by the release build, or unknown.
backendSTORAGE_BACKEND_POSTGRES, STORAGE_BACKEND_SQLITE, or STORAGE_BACKEND_FOUNDATIONDB.
node_idUUID for this running server process. It changes when the process restarts.
capabilitiesTyped features supported by the connected server.

Capabilities currently report Command Context Consistency, catch-up subscriptions, index management, the boundary catalog, and standard gRPC health. Clients should check for the capability they need instead of inferring support from the version string.

CreateIndex​

On PostgreSQL and SQLite, repeating the same definition is idempotent. An existing logical name or physical index with a different definition returns ALREADY_EXISTS. To change a definition, call DropIndex first; a conflicting CreateIndex never overwrites the existing definition.

_, err := client.CreateIndex(ctx, &eventstore.CreateIndexRequest{
Boundary: "orders",
Name: "customer_id",
Fields: []*eventstore.IndexField{{
JsonKey: "customer_id",
ValueType: eventstore.ValueType_TEXT,
}},
})

value_type is TEXT, NUMERIC, BOOLEAN, or TIMESTAMPTZ. Add conditions for a partial index. Each condition operator must be one of =, >, <, >=, or <=. See Indexing for composite and partial index examples.

ListIndexes and GetIndex​

Both calls return Orisun-managed index definitions, including fields, partial conditions, the condition combinator, and BUILDING or READY state. GetIndex returns NOT_FOUND when the logical name is not registered.

list, err := client.ListIndexes(ctx, "orders")
one, err := client.GetIndex(ctx, "orders", "customer_id")

ADMIN and OPERATIONS users can inspect indexes. Creating and dropping indexes remains restricted to ADMIN.

DropIndex​

_, err := client.DropIndex(ctx, &eventstore.DropIndexRequest{
Boundary: "orders",
Name: "customer_id",
})

Handling consistency conflicts​

When any command context changed between read and write, SaveEventsV2 returns ALREADY_EXISTS. Treat it as a retryable business conflict: re-read every context needed by the decision, re-decide, and save again with fresh observations.

var conflict *orisun.OptimisticConcurrencyException
if errors.As(err, &conflict) {
log.Printf("consistency conflict: expected=%v actual=%v",
conflict.ExpectedVersion(), conflict.ActualVersion())
}

Proto source​

The EventStore protobuf source lives at proto/eventstore.proto.

Common status codes​

StatusMeaning
INVALID_ARGUMENTThe request is malformed, uses invalid JSON, or references invalid index fields.
UNAUTHENTICATEDMissing or invalid credentials.
PERMISSION_DENIEDAuthenticated user does not have a required role.
FAILED_PRECONDITIONThe boundary is not active, or a FoundationDB criterion has neither a native position range nor a ready covering secondary index.
ALREADY_EXISTSOne or more observations changed during SaveEventsV2; re-query and retry if still valid.
INTERNALStorage, publishing, or unexpected server failure.