Key-Value Store

A Key-Value bucket is an opinionated JetStream stream (KV_<bucket>, subjects $KV.<bucket>.>). KV handles are obtained from the JetStream context. Reads use JetStream Direct Get when the bucket allows it (allowDirect, the default), so a get returns the raw message payload with no JSON/base64 decoding on the hot path; writes ride the JetStream publish/coalescing window. A runnable version of this page is KeyValueExample.scala on GitHub.

The snippets below share these imports and helpers:

import cats.effect.IO
import fs2.Chunk
import fs2.nats.client.NatsClient
import fs2.nats.kv.*

def onChange(key: String, value: Chunk[Byte], op: KvOperation): IO[Unit] =
  IO.println(s"$key = ${value.size} bytes ($op)")

Put, get, and optimistic concurrency

put returns the new revision (the entry's stream sequence). update only writes if the revision still matches, raising NatsError.KeyValueWrongLastSequence otherwise:

def kvBasics(client: NatsClient[IO]): IO[Unit] =
  client.jetStream().use { js =>
    for
      // Create a bucket keeping the last 5 revisions of each key
      kv   <- js.createKeyValue(KvConfig(bucket = "config", history = 5))

      // Put returns the new revision
      rev  <- kv.put("db.url", Chunk.array("postgres://localhost".getBytes))
      cur  <- kv.get("db.url")                 // Option[KvEntry] (Direct Get)

      // Optimistic concurrency: only writes if the revision still matches
      rev2 <- kv.update("db.url", Chunk.array("postgres://prod".getBytes), rev)

      // delete writes a tombstone; purge collapses a key's history
      _    <- kv.delete("legacy")
      keys <- kv.keys.compile.toList           // live keys (excludes deletes)

      _    <- IO.println(s"current=$cur updated-rev=$rev2 live-keys=${keys.size}")
    yield ()
  }

create (fails if the key exists) and update raise NatsError.KeyValueWrongLastSequence when their optimistic-concurrency precondition fails.

A KvEntry carries more than the value: its revision, created timestamp, delta (distance from the latest revision — 0 means current) and operation (Put, or the Delete/Purge tombstones).

For pipelined writes, putAsync returns nested effects: the outer one completes once the publish is on the wire, the inner one yields the revision.

History and point-in-time reads

With history > 1 the bucket retains up to that many revisions per key (server limit: 64). history(key) lists them oldest-first, and get with a revision reads one directly. delete writes a tombstone on top; purge instead collapses the key's whole history into a single Purge marker:

def kvHistory(kv: KeyValue[IO]): IO[Unit] =
  for
    revs <- kv.history("db.url")
    _    <- IO.println(revs.map(e => s"${e.revision}: ${e.operation}").mkString(", "))
    entry <- kv.get("db.url", revision = revs.head.revision)
    _     <- IO.println(s"first revision: ${entry.map(_.value.size)} bytes")
    _     <- kv.purge("legacy")
  yield ()

Watch

Watch delivers the current entries, then a single KvWatchEvent.EndOfData marker, then live changes:

def kvWatch(kv: KeyValue[IO]): IO[Unit] =
  kv.watch(">").use { stream =>
    stream.evalMap {
      case KvWatchEvent.Entry(e)  => onChange(e.key, e.value, e.operation)
      case KvWatchEvent.EndOfData => IO.println("caught up")
    }.compile.drain
  }

The first argument is a key pattern and accepts the NATS wildcards * and >watch("config.>") follows a subtree, watchAll() is shorthand for the whole bucket. WatchOptions refines the delivery: includeHistory replays every retained revision, updatesOnly skips the initial snapshot (and takes precedence over includeHistory if both are set), ignoreDeletes drops the Delete/Purge tombstones, and metaOnly delivers entries without their values:

def kvUpdates(kv: KeyValue[IO]): IO[Unit] =
  kv.watch("config.>", WatchOptions(updatesOnly = true, ignoreDeletes = true))
    .use(_.compile.drain)

keys/history/watch stream from a gap-resetting ordered consumer, so a reconnect mid-watch recovers in order rather than missing updates.

Bucket configuration and management

KvConfig exposes the bucket options beyond history: a per-entry ttl, storage (File/Memory), size limits (maxBytes, maxValueSize-1 means unlimited), replicas, S2 compression, and allowDirect (disable it to route reads through STREAM.MSG.GET instead of Direct Get):

import scala.concurrent.duration.*
import fs2.nats.jetstream.protocol.{StorageType, StoreCompression}

val sessionsConfig = KvConfig(
  bucket = "sessions",
  history = 1,
  ttl = Some(30.minutes),
  storage = StorageType.Memory,
  maxValueSize = 64 * 1024,
  compression = StoreCompression.S2
)

Bucket management lives on the JetStream context: createKeyValue, keyValue (bind to an existing bucket), deleteKeyValue (destroys the bucket and its data), keyValueStatus, and keyValueNames (a Stream of bucket names). The handle itself reports its bucket via kv.bucket and its live state via kv.status — a KvStatus with the value count, retained history depth, TTL, total bytes, storage type and replica count.

Bucket names must match [A-Za-z0-9_-]+; keys may contain alphanumerics and -/_=. but must not start or end with a dot. Invalid names fail with NatsError.InvalidSubject.

Next: the Object Store for large binary objects.