novadocs
Guides

Guides

Go SDK

Create buckets and streams, append with a producer or one-shot, read, subscribe, trim, and fence from Go

The Go SDK is github.com/supabase/nova/client: a thin client over the data plane that routes producers to a stream's leader, heals broken sessions, batches appends, and exchanges an api-key for tokens on its own. This page walks the whole exported surface; every snippet matches the real signatures.

Install and connect

The module is github.com/supabase/nova (Go 1.26 or newer). Import the client package and build one Client per process; it is safe for concurrent use.

Connect to novad
import "github.com/supabase/nova/client"

c, err := client.New(client.Options{
    Addrs: []string{"localhost:7781"},
})
if err != nil {
    return err
}
defer c.Close()

Options has six fields:

FieldMeaningDefault
AddrsAddresses of the fleet. One suffices: any star (a running novad) serves reads and redirects producers to the leader; an LB or DNS name works. Extra entries are connection-failure fallbacks.required
DialTimeoutBound on one connection attempt5s
RetryBudgetHow long transient failures are retried invisibly before ErrUnavailable surfaces30s
TLS*tls.Config; nil dials plaintext. client.ServerTLS(caFile) and client.MutualTLS(certFile, keyFile, caFile) build one (empty CA path uses system roots).nil
AuthA credential attached to every request: client.Bearer(token), client.BearerFunc(fn), or client.BasicAuth(user, pass)nil
APIKey*client.APIKeyCredential; the SDK exchanges it for short-lived tokens itself. Mutually exclusive with Auth.nil

The address form picks the plane

A plain host:port speaks gRPC, the wire. An http:// or https:// URL speaks the HTTP surface: the data bridge on novad or the Gateway. One client speaks one plane; New rejects a mixed list, an unknown scheme, or a TLS config beside an http:// address. Under https://, TLS is used when set and system roots apply when nil.

Two things are wire-only: producer sessions (OpenStream) and the admin plane (NewAdmin). On an HTTP-surface client they fail immediately with client.ErrWireOnly.

Authenticating with an api-key

Hand the client the pair and it mints, caches (one token per target bucket), and refreshes tokens through the exchange; the key itself never authenticates anything else. Calls that name a bucket derive their ask from it; bucket-less calls (ListBuckets) need a default: set Bucket (grants narrowed to that bucket) or Scope (an explicit grant subset), never both. ExchangeURL points minting at an external endpoint speaking the same JSON exchange envelope. See authentication.

c, err := client.New(client.Options{
    Addrs:  []string{"localhost:7781"},
    APIKey: &client.APIKeyCredential{KeyID: "ck_app", Secret: secret, Bucket: "apps"},
})

Buckets

Every stream is created in a bucket that must already exist. EnsureBucket is create-or-join; CreateBucket with the same CreationToken converges on a retry.

b, err := c.EnsureBucket(ctx, client.Bucket{
    Name:           "apps",
    CreateOnAppend: true,
    Defaults:       client.StreamOptions{Class: client.Express},
})

Bucket carries Name, Location, the CreateOnAppend / CreateOnRead flags, Defaults (the default stream configuration copied into every stream created in the bucket), CreationToken, and the nova-owned CreatedAt / DeletedAt. The other verbs are GetBucket(ctx, name), UpdateBucket(ctx, b) (flags act immediately, defaults govern future streams), DeleteBucket(ctx, name) (only when empty), and ListBuckets(ctx, after, limit) returning a page and the next cursor.

Streams

CreateStream takes StreamOptions; a zero field is filled from the bucket's defaults. The field naming the bucket is Bucket.

created, err := c.CreateStream(ctx, client.StreamOptions{
    Bucket:   "apps",
    Key:      "orders/eu",
    Class:    client.Standard,
    Producer: client.ProducerFenced,
    Retention: client.RetentionPolicy{MaxAge: 7 * 24 * time.Hour},
    Timestamping: client.Timestamping{Mode: client.ClientPrefer},
})
// created.ID is the minted StreamID (informational); created.Key, ...

The enum values are client.Standard / client.Express, client.ProducerAny / client.ProducerFenced, and client.ClientPrefer / client.ClientRequire / client.Arrival; the zero value of each means "the bucket default's". RetentionPolicy{MaxAge, MaxBytes}, BatchPolicy{MaxBytes, MaxRecords, MaxDelay}, ThroughputPolicy{MaxBytesPerSecond}, and Timestamping{Mode, Uncapped} round out the options; zero bounds mean unbounded.

A stream is named by its client.Address{Bucket, Key} — every verb takes one. The StreamID nova mints at creation comes back in outputs (created.ID, GetStream) as the handle that correlates a stream with log lines and settled objects; no verb accepts it. GetStream(ctx, addr), ListStreams(ctx, bucket, client.ListOptions{After, Limit}) (per bucket, never across buckets; page cap 1000; After is the opaque Cursor the previous page returned as next), and DeleteStream(ctx, addr). Delete is idempotent — deleting the deleted succeeds; the stream is gone for every caller on return and reclaimed in the background. client.ParseStreamID parses the UUID text form, for reading IDs out of logs.

Reconfiguring

ReconfigureStream(ctx, addr, client.StreamReconfiguration{…}) changes only the fields you set; every field is a pointer, so a nil field is untouched and a pointer to zero sets zero.

unbounded := time.Duration(0)
bps := int64(1 << 20)

st, err := c.ReconfigureStream(ctx, addr, client.StreamReconfiguration{
    RetentionMaxAge: &unbounded, // back to unbounded — a create cannot say this
    Throughput:      &bps,
})

Retention takes effect at once. Class, producer policy, timestamping, throughput, and batch are held by the stream's leader for its tenure, so a change to any of them hands the stream off: a live Producer reconnects on its own, and once the call returns every later append honors the new configuration; acknowledged records keep the class they were written under. A change to a class the serving star cannot write fails with FailedPrecondition before anything changes.

Appending with a producer

OpenStream takes the stream's writer role and returns a Producer over an append session; it creates the stream first when its bucket sets CreateOnAppend.

p, err := c.OpenStream(ctx, client.Address{Bucket: "apps", Key: "orders/eu"})
if err != nil {
    return err
}
defer p.Close(ctx)

res, err := p.Append(ctx,
    client.AppendRecord{
        Body:    []byte(`{"id":1}`),
        Headers: []client.Header{{Name: []byte("content-type"), Value: []byte("application/json")}},
    },
    client.AppendRecord{Body: []byte(`{"id":2}`)},
)
// res.Start.SeqNum, res.End.SeqNum (exclusive), res.Tail

client.Bodies(a, b, ...) wraps raw payloads: p.Append(ctx, client.Bodies(a, b)...). Each AppendRecord carries Body, up to 100 Headers (name/value bytes, names non-empty, duplicates kept in order), and an optional TimestampMs *int64 honored per the stream's timestamping mode; absent under client-require or present under arrival refuses the batch. A record's size is its content bytes: body plus every header name and value.

AppendResult is {Start, End, Tail Position}: Start is the first appended record, End one past the last, Tail the committed tail as of the ack. A Position is {SeqNum uint64, TimestampMs int64}.

Batching and linger

Appends coalesce naturally. AppendAsync stages records and returns a channel; a single send loop cuts wire batches of up to 1 MiB whenever a window slot frees (novad advertises the window at the handshake, 16 by default). An idle append goes out immediately; under load, whatever accumulated rides the next slot as one batch, and every caller still resolves with exactly its own records' range. The staged buffer admits 1 MiB behind a full window, then blocks: that is the backpressure.

p, err := c.OpenStream(ctx, addr,
    client.WithBatchLinger(5*time.Millisecond), // wait up to 5ms for a fuller batch (max 1s)
    client.WithBatchSize(256<<10),              // cut at 256 KiB instead of 1 MiB
)

WithBatchLinger trades latency for batch size on high-rate small-record pipelines; the default is zero. WithBatchSize only caps how much one batch carries. Close never waits on a linger.

Conditional appends

AppendMatch(ctx, match, records...) stores the batch only if novad's next assignment is exactly match. A miss returns a *client.SequenceError (matching client.ErrSequence) carrying Tail (retry at Tail.SeqNum or read back and decide), and the producer stays usable. A CAS batch never merges with others and is exactly-once across reconnects: a resend re-evaluates its match instead of duplicating. Resent on the error tells you whether an earlier attempt could have landed.

Reconnects

A broken wire triggers reconnect, re-handshake, and a resend of the unacked tail within RetryBudget; plain appends are therefore at-least-once under a lost ack. A NOT_OWNER answer is followed to the leader's address automatically, and a fenced-out or not-found refusal surfaces at once. Past the budget every pending and future append resolves ErrUnavailable.

Close

p.Close(ctx) flushes the staged buffer, drains the unacked window (bounded by ctx), releases the writer role, and ends the session. A later OpenStream continues the sequence.

One-shot appends

For bursts that do not justify a session, append straight from the client: one call, one durable batch. On Any streams the leader keeps the role warm between calls.

res, err := c.Append(ctx, addr, client.Bodies([]byte("hello"))...)
res, err = c.AppendMatch(ctx, addr, 1, client.Bodies([]byte("cas"))...)

A one-shot CAS miss is always definitive: nothing was appended. A retry after an ambiguous outcome may duplicate; resolve with CheckTail and a suffix read.

Reading

Reads need no producer and are served by any star.

page, err := c.Read(ctx, addr, 0, 100)                        // from seq_num 0
page, err = c.ReadFrom(ctx, addr, sinceMs, 100)               // first record stamped at or after sinceMs
page, err = c.ReadTail(ctx, addr, 10, 10)                     // the last 10 records
page, err = c.Read(ctx, addr, 0, 100, client.WithClamp())     // clamp an out-of-range start
for _, r := range page.Records {
    _ = r.SeqNum; _ = r.TimestampMs; _ = r.Headers; _ = r.Body
}

ReadResult is {Records []Record, Tail Position}; no records means caught up. Options: WithClamp() pulls a start below the trim point or past the tail into range instead of erroring; WithUntil(ms) cuts records stamped at or after ms; WithMaxBytes(n) caps the page (novad clamps to its own budget and always returns at least one record). limit clamps to novad's page cap.

Subscribing

Subscribe returns a range-able iterator of batches from from onward, pending at the tail as records commit. A broken wire reconnects and resumes at the last delivered sequence number plus one; you never see the seam.

for batch, err := range c.Subscribe(ctx, addr, 0) {
    if err != nil {
        return err // trimmed, not found, or the retry budget exhausted
    }
    for _, r := range batch {
        handle(r)
    }
}

SubscribeFrom(ctx, addr, ms) starts at the first record stamped at or after ms (resolved once; resumes are by sequence number). Cancelling ctx ends the loop quietly; a token expiring under the subscription reopens with a fresh credential without spending retry budget.

Tail, trim, fence, delete

tail, err := c.CheckTail(ctx, addr)      // tail.SeqNum: the next sequence number; 0 on a virgin stream
err = c.Trim(ctx, addr, 1000)            // records below 1000 are logically gone
err = c.TrimFrom(ctx, addr, cutoffMs)    // trim to the first record stamped at or after cutoffMs

CheckTail is strongly consistent: served by the writer's holder when one exists, from committed state otherwise. Trim is monotonic: trimming backwards is a no-op.

Fence alone installs fencing tokens; opens and appends present one, judged by exact equality.

boundary, err := c.Fence(ctx, addr, "writer-2")        // installs; supersedes sessions presenting anything else
p, err := c.OpenStream(ctx, addr, client.WithFencingToken("writer-2"))
res, err := c.Presenting("writer-2").Append(ctx, addr, client.Bodies(b)...)
installed, err := c.FencingToken(ctx, addr)             // discover the current token; "" when unfenced
_, err = c.Fence(ctx, addr, "")                         // clear

Takeover is fence-then-open: fence a fresh token (at most 36 UTF-8 bytes), then open presenting it. Presenting(token) returns a cheap view whose one-shot appends carry the token (Append, AppendMatch). On a Fenced stream presenting is mandatory while a token is set; on an Any stream only writers that present are bound.

Errors

Sentinels wrap novad's reason, so errors.Is branches and the printed error still says why.

SentinelWhen
ErrStreamNotFoundThe stream (or bucket) does not exist
ErrSequence / *SequenceErrorA match was not the next assignment; Tail is the committed tail
ErrFencedOut / *FenceErrorPresented token is not the installed one; Installed carries what stands
ErrRecordTooLargenovad refused the batch shape: a record over 1 MiB, a batch over its caps, bad headers or timestamps
ErrTrimmedA read below the trim watermark (use WithClamp)
ErrUnavailableThe retry budget ran out: the fleet was unreachable or shed for the whole window
ErrUnauthenticatedMissing or rejected credential
ErrHolderUnresolvableA redirect named a leader address this client cannot resolve: a deployment error (--advertise), never cured by retry
ErrWireOnlyProducer sessions or the admin plane on an HTTP-surface client

Anything else arrives as the raw gRPC status.

The admin plane

client.NewAdmin(opts) dials exactly one star (the first address) and exposes Health, ListStars, Drain(ctx, stop), Decommission(ctx, star, client.DecommissionOptions{Force, CoolDown}), CreateCredential(ctx, scope) (returns the secret exactly once), ListCredentials(ctx, after, limit), and RevokeCredential(ctx, keyID). ExchangeToken(ctx, client.ExchangeRequest{KeyID, Secret, Bucket, Scope, TTL}) on the data client performs one manual exchange when you need a token to hand elsewhere.

Source documents

  • client/: the SDK source
  • docs/design/027-sdk-planes.md
  • docs/adr/0020-client-natural-batching.md
  • docs/adr/0038-address-form-selects-the-plane.md
  • docs/adr/0039-fence-only-token-installation.md

On this page