Skip to main content
Version: 0.9.4

EventStore API

The EventStore service owns event operations:

  • SaveEvents
  • GetEvents
  • 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

event_type is the API field for the event's type. On save, Orisun writes that value into the stored event data as the canonical eventType JSON key, and storage backends derive response event_type from data.eventType. Criteria and indexes should use the eventType JSON key.

SaveEvents​

result, err := client.SaveEvents(ctx, &eventstore.SaveEventsRequest{
Boundary: "orders",
Query: &eventstore.SaveQuery{
ExpectedPosition: &eventstore.Position{CommitPosition: -1, PreparePosition: -1},
},
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 committed log position:

{
"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 a consistency subset​

Use query.subsetQuery to enforce Command Context Consistency for a specific event subset:

subsetQuery defines the dynamic set of events the command depends on, and expected_position is the position of the latest matching event observed when that set was read. The append succeeds only if the latest event matching subsetQuery is still at exactly expected_position. Use the context_position returned by GetLatestByCriteria, or the latest matching position from a complete GetEvents read; an arbitrary later position such as the store head is not a valid substitute.

_, err := client.SaveEvents(ctx, &eventstore.SaveEventsRequest{
Boundary: "orders",
Query: &eventstore.SaveQuery{
ExpectedPosition: &eventstore.Position{CommitPosition: 12, PreparePosition: 8},
SubsetQuery: &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 subset is no longer at the expected position, Orisun returns ALREADY_EXISTS. Treat that as a CCC conflict: re-read the context, decide again, and retry only if the command is still valid.

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.

GetLatestByCriteria​

GetLatestByCriteria returns the latest event matching each criterion, assembled by the server from one consistent read snapshot, plus a context_position to use as the expected_position of the next SaveEvents with the same combined criteria. It is the command-side read for the carried-state pattern: store the resulting state (for example an account balance) on each event, then a command needs only the latest event per entity, not 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.
// resp.ContextPosition is the next expected_position for a SaveEvents
// using the same combined criteria.
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.

Why a dedicated RPC instead of separate GetEvents calls: two calls are two snapshots. An event can commit between them with a position below the maximum you observed, and a scalar expected_position can only prove "nothing newer than X exists." It cannot prove your reads saw everything up to X. GetLatestByCriteria closes that gap by sampling the whole context 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​

_, 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 a command's context changed between read and write, SaveEvents returns ALREADY_EXISTS. Treat it as a retryable business conflict: re-read the context, re-decide, and save again with the new position.

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.
ALREADY_EXISTSOptimistic consistency conflict during SaveEvents; re-query and retry if still valid.
INTERNALStorage, publishing, or unexpected server failure.