Skip to content
English

JetStream KV + Object Store

JetStream is three APIs, not one. JetStreamActor covers streams and consumers; this page covers the other two:

  • JetStreamKeyValueActor — a KV bucket: revisioned keys, compare-and-swap writes, and a watch change feed.
  • JetStreamObjectStoreActor — an object bucket: named blobs with metadata.

Both are separate actors because they are separate sub-APIs with separate semantics — a bucket is not a stream + consumer, and folding them into the stream actor would have given it a sixth mode nobody wants.

import { ActorSystem, Actor, JetStreamKeyValueActor, JetStreamKeyValueOptions } from 'actor-ts';
import type { ActorRef, JetStreamKeyValueMessage } from 'actor-ts';
const sessionOptions = JetStreamKeyValueOptions.create()
.withServers(['nats://localhost:4222'])
.withBucket('sessions')
.withHistory(5)
.withTimeToLive(3_600_000);
const sessions = system.spawn(() => new JetStreamKeyValueActor(sessionOptions), 'sessions');
// Fire-and-forget write:
sessions.tell({ kind: 'put', key: 'user.42', value: JSON.stringify(session) });
// Read — the answer arrives at `reader` as a kind-tagged message:
sessions.tell({ kind: 'get', key: 'user.42', target: reader });

The builder is the primary style; a plain object works too. Common broker fields — withReconnect, withCircuitBreaker, withOutboundBuffer — come from the shared BrokerActor base. servers and bucket are required.

kindFieldsPurpose
putkey, value, expectedRevision?, target?Write a value; with expectedRevision it is a compare-and-swap.
getkey, targetRead the current value.
deletekey, target?Remove the key, leaving a tombstone in its history.
purgekey, target?Remove the key and its history.
keysfilter?, targetList the live keys, optionally narrowed by a subject filter.
watchkey?, targetStream every change under key (default '>' — the whole bucket).
unwatchkey?Stop that watch.

target is required where an answer is the point (get, keys, watch) and optional where it is a receipt (put, delete, purge) — omit it for fire-and-forget writes and the failures are logged instead.

Every answer is a JetStreamKeyValueMessage, discriminated on kind:

kindFieldsWhen
keyValueEntrykey, value, revision, createdAtA get hit, and every watch update.
keyValueNotFoundkeyA get on a missing or deleted key.
keyValueRemovedkey, purgedReceipt for delete / purge, and every watched removal.
keyValueRevisionkey, revisionReceipt for a successful put.
keyValueKeyskeysAnswer to keys.
keyValueOperationFailedoperation, key?, reasonThe operation failed — most often a compare-and-swap conflict.
class SessionReader extends Actor<JetStreamKeyValueMessage> {
override onReceive(message: JetStreamKeyValueMessage): void {
if (message.kind === 'keyValueEntry') {
this.log.info(`${message.key} @ rev ${message.revision}`);
}
}
}

revision is the concurrency token. Read it, compute the new value, and write it back with expectedRevision — the server rejects the write if anyone else moved the key in between, and the actor answers keyValueOperationFailed so the caller can retry against the fresh value:

// after a `get` answered with { revision: 7 }
sessions.tell({
kind: 'put',
key: 'user.42',
value: JSON.stringify(next),
expectedRevision: 7,
target: reader,
});

Pass expectedRevision: 0 to mean “only if the key does not exist yet”.

A watch is desired state, not a one-shot subscription: it is re-established on every reconnect, and one issued while the actor is disconnected lands on the next connect rather than being dropped. That is the same guarantee NatsActor gives a subscription, and it matters more here — a change feed that silently stops looks exactly like a bucket that stopped changing.

sessions.tell({ kind: 'watch', key: 'user.>', target: reader });
// … later
sessions.tell({ kind: 'unwatch', key: 'user.>' });

Re-watching a live key swaps the target.

interface JetStreamKeyValueOptionsType extends BrokerCommonOptionsType {
servers?: string[] | string; // NATS server URLs (required)
token?: string;
user?: string;
password?: string;
name?: string; // client identifier
bucket?: string; // bucket name (required)
history?: number; // revisions kept per key (create-time)
timeToLive?: number; // per-key TTL in ms (create-time)
storage?: 'memory' | 'file'; // create-time
replicas?: number; // create-time
maxValueBytes?: number; // server-side cap on one value (create-time)
create?: boolean; // create the bucket when missing; default true
}

The create-time fields are only sent when the actor creates the bucket. With create: false the actor binds to an existing bucket and fails the connect if it is missing — the right setting when an operator provisions the bucket and a typo should not quietly produce an empty one.

HOCON defaults live under actor-ts.io.broker.jetstream-key-value:

actor-ts.io.broker.jetstream-key-value {
servers = ["nats://localhost:4222"]
bucket = "sessions"
history = 5
}
import { JetStreamObjectStoreActor, JetStreamObjectStoreOptions } from 'actor-ts';
const assetOptions = JetStreamObjectStoreOptions.create()
.withServers(['nats://localhost:4222'])
.withBucket('assets');
const assets = system.spawn(() => new JetStreamObjectStoreActor(assetOptions), 'assets');
assets.tell({ kind: 'put', name: 'report.pdf', payload: bytes, target: uploader });
assets.tell({ kind: 'get', name: 'report.pdf', target: reader });
kindFieldsPurpose
putname, payload, description?, headers?, target?Store a body, replacing any previous one.
getname, targetRead the whole body.
deletename, target?Remove the object.
infoname, targetMetadata only — no body transfer, whatever the size.
listtargetMetadata for every live object in the bucket.
kindFieldsWhen
objectStoredname, infoReceipt for put.
objectBodyname, payload, infoAnswer to get.
objectInfoname, infoAnswer to info.
objectListobjectsAnswer to list.
objectDeletednameReceipt for delete.
objectNotFoundnameThe object is missing or deleted.
objectStoreOperationFailedoperation, name?, reasonThe operation failed, including an over-the-ceiling body.

info carries name, size, chunks, digest, modifiedAt, and the optional description / headers the object was stored with.

interface JetStreamObjectStoreOptionsType extends BrokerCommonOptionsType {
servers?: string[] | string; // NATS server URLs (required)
token?: string;
user?: string;
password?: string;
name?: string; // client identifier
bucket?: string; // bucket name (required)
description?: string; // create-time
storage?: 'memory' | 'file'; // create-time
replicas?: number; // create-time
maxObjectBytes?: number; // whole-object ceiling; default 1 MiB
create?: boolean; // create the bucket when missing; default true
}

HOCON defaults live under actor-ts.io.broker.jetstream-object-store:

actor-ts.io.broker.jetstream-object-store {
servers = ["nats://localhost:4222"]
bucket = "assets"
maxObjectBytes = 4M
}

Raise maxObjectBytes only as far as outboundBuffer × maxObjectBytes stays a resident-memory figure you are willing to hold through an outage; drop outboundBuffer to 0 if you would rather fail fast than buffer blobs at all. info and list are unaffected — metadata carries no body, so they answer for objects of any size.

For genuinely large objects, use the nats client’s chunked put/get streams directly. Wiring those through the actor needs a dispatch path outside the connection state machine, which is a larger change than this actor.

Terminal window
npm install nats
# or: bun add nats

The same nats package that backs NATS and JetStream — the KV and Object-Store views ship with it.

  • NATS JetStream — durable streams and consumers, the third sub-API.
  • NATS — core (non-durable) pub/sub + request-reply.
  • BrokerActor base — the shared reconnect / buffer / circuit-breaker lifecycle.
  • I/O overview — the bigger picture.