Key Operations
All key operations are performed through a bucket handle. Get a handle by calling kv.bucket("name") with the name of an existing bucket.
Writing Values
Use put to unconditionally write a value. If the key already exists, its value is overwritten. The call returns the new revision number.
import { createClient } from "@ironflow/node";
const client = createClient({ serverUrl: "http://localhost:9123" });const kv = client.kv();const bucket = kv.bucket("my-bucket");
const { revision } = await bucket.put("user:123", { name: "Alice", role: "admin" });console.log("Revision:", revision); // 1import { ironflow } from "@ironflow/browser";
ironflow.configure({ serverUrl: "http://localhost:9123" });const bucket = ironflow.kv().bucket("my-bucket");
const { revision } = await bucket.put("user:123", { name: "Alice", role: "admin" });console.log("Revision:", revision); // 1kv := client.KV()bucket := kv.Bucket("my-bucket")
revision, err := bucket.Put(ctx, "user:123", []byte(`{"name":"Alice","role":"admin"}`))if err != nil { log.Fatal(err)}fmt.Println("Revision:", revision) // 1Reading Values
Use get to read a value by key. The returned entry includes the key, value, revision number, creation timestamp, and the last operation type.
Values are stored as raw bytes on the server. The SDK returns entry.value as unknown — the wire encoding is base64 for binary content and a JSON string for JSON content. The SDK does not auto-decode; if you stored JSON, parse it on read (JSON.parse(atob(entry.value as string))) or store as a string explicitly.
const entry = await bucket.get("user:123");
console.log(entry.key); // "user:123"console.log(entry.value); // base64 string of stored bytes (decode + JSON.parse to recover an object)console.log(entry.revision); // 1console.log(entry.created_at); // "2026-02-22T10:00:00Z"console.log(entry.operation); // "put"const entry = await bucket.get("user:123");
console.log(entry.key); // "user:123"console.log(entry.value); // base64 string of stored bytes (decode + JSON.parse to recover an object)console.log(entry.revision); // 1console.log(entry.created_at); // "2026-02-22T10:00:00Z"console.log(entry.operation); // "put"entry, err := bucket.Get(ctx, "user:123")if err != nil { log.Fatal(err)}
fmt.Println(entry.Key) // "user:123"fmt.Println(entry.Value) // []byte(`{"name":"Alice","role":"admin"}`)fmt.Println(entry.Revision) // 1fmt.Println(entry.CreatedAt) // "2026-02-22T10:00:00Z"fmt.Println(entry.Operation) // "put"Deleting Values
Soft Delete
Use delete to place a tombstone marker on the key. The key no longer appears in reads or listings, but its history is preserved according to the bucket’s history setting.
await bucket.delete("user:123");// Key is tombstoned — get() now throws IronflowError with code "HTTP_404"await bucket.delete("user:123");// Key is tombstoned — get() now throws IronflowError with code "HTTP_404"err := bucket.Delete(ctx, "user:123")if err != nil { log.Fatal(err)}// Key is tombstoned — Get() will return an errorHard Delete (Purge)
Use purge to permanently remove a key and all of its revision history. This is irreversible.
await bucket.purge("user:123");// Key and all history permanently removedawait bucket.purge("user:123");// Key and all history permanently removederr := bucket.Purge(ctx, "user:123")if err != nil { log.Fatal(err)}// Key and all history permanently removedAtomic Operations
Ironflow supports revision-based concurrency control for safe concurrent writes. Every write to a key increments its revision number. Atomic operations use this revision to detect conflicts and prevent lost updates.
Create (If-Not-Exists)
Use create to write a value only if the key does not already exist. If the key exists, the operation fails with HTTP 412. This is useful for safe initialization of keys that should only be set once.
try { const { revision } = await bucket.create("config:feature-flags", { darkMode: true, betaAccess: false, }); console.log("Created at revision:", revision);} catch (err) { // Key already exists (HTTP 412) console.error("Key already exists:", err.message);}try { const { revision } = await bucket.create("config:feature-flags", { darkMode: true, betaAccess: false, }); console.log("Created at revision:", revision);} catch (err) { // Key already exists (HTTP 412) console.error("Key already exists:", err.message);}revision, err := bucket.Create(ctx, "config:feature-flags", []byte(`{"darkMode":true,"betaAccess":false}`))if err != nil { // Key already exists (HTTP 412) log.Println("Key already exists:", err)} else { fmt.Println("Created at revision:", revision)}Update (Compare-and-Swap)
Use update to write a value only if the key’s current revision matches the one you provide. This prevents lost updates when multiple clients modify the same key concurrently. The typical pattern is read-modify-write:
- Read the current entry to get the latest revision.
- Modify the value locally.
- Update with the revision from step 1. If another client wrote in between, the revision will have changed and the update fails with HTTP 412.
// 1. Read the current valueconst entry = await bucket.get("user:123");
// 2. Modify locallyconst updated = { ...entry.value, role: "superadmin" };
// 3. Write back with the revision guardtry { const { revision } = await bucket.update("user:123", updated, entry.revision); console.log("Updated to revision:", revision);} catch (err) { // Revision mismatch (HTTP 412) — another client wrote first console.error("Conflict — retry with fresh data:", err.message);}// 1. Read the current valueconst entry = await bucket.get("user:123");
// 2. Modify locallyconst updated = { ...entry.value, role: "superadmin" };
// 3. Write back with the revision guardtry { const { revision } = await bucket.update("user:123", updated, entry.revision); console.log("Updated to revision:", revision);} catch (err) { // Revision mismatch (HTTP 412) — another client wrote first console.error("Conflict — retry with fresh data:", err.message);}// 1. Read the current valueentry, err := bucket.Get(ctx, "user:123")if err != nil { log.Fatal(err)}
// 2. Modify locallynewValue := []byte(`{"name":"Alice","role":"superadmin"}`)
// 3. Write back with the revision guardrevision, err := bucket.Update(ctx, "user:123", newValue, entry.Revision)if err != nil { // Revision mismatch (HTTP 412) — another client wrote first log.Println("Conflict — retry with fresh data:", err)} else { fmt.Println("Updated to revision:", revision)}Listing Keys
Use listKeys to retrieve all keys in a bucket, optionally filtered by a wildcard pattern.
Wildcard filters follow NATS subject-matching rules, where . is the token separator. A wildcard like user.* matches keys structured as user.123, not user:123 — the latter is a single token with no separator. If you need prefix-style filtering, use . as the separator in your key names.
// List all keysconst allKeys = await bucket.listKeys();console.log(allKeys); // ["user.123", "user.456", "config.feature-flags"]
// Filter with a wildcard pattern (keys must use "." separators for this to match)const userKeys = await bucket.listKeys("user.*");console.log(userKeys); // ["user.123", "user.456"]// List all keysconst allKeys = await bucket.listKeys();console.log(allKeys); // ["user.123", "user.456", "config.feature-flags"]
// Filter with a wildcard pattern (keys must use "." separators for this to match)const userKeys = await bucket.listKeys("user.*");console.log(userKeys); // ["user.123", "user.456"]// List all keysallKeys, err := bucket.ListKeys(ctx, "")if err != nil { log.Fatal(err)}fmt.Println(allKeys) // ["user.123", "user.456", "config.feature-flags"]
// Filter with a wildcard pattern (keys must use "." separators for this to match)userKeys, err := bucket.ListKeys(ctx, "user.*")if err != nil { log.Fatal(err)}fmt.Println(userKeys) // ["user.123", "user.456"]Wildcard patterns follow NATS subject-matching rules. . is the token separator:
| Pattern | Matches | Does not match |
|---|---|---|
* | Any single-token key (user, flag) | Multi-token keys (user.123) |
user.* | user.123, user.456 | user.123.name, user:123 |
> | One or more tokens (matches everything) | — |
user.> | user.123, user.123.name, user.123.name.first | config.flags, user:123 |
Watching for Changes
Watch provides real-time notifications when keys are created, updated, or deleted. Changes are delivered over WebSocket and are available in the browser SDK.
import { ironflow } from "@ironflow/browser";
const bucket = ironflow.kv().bucket("my-bucket");
// Watch all keys matching "user.*"const watcher = bucket.watch({ onUpdate: (event) => { console.log(`${event.key} ${event.operation}: rev ${event.revision}`); console.log("New value:", event.value); console.log("Bucket:", event.bucket); }, onError: (err) => console.error(err), onClose: () => console.log("Watch ended"),}, { key: "user.*" });
// Later: stop watchingwatcher.stop();Each KVWatchEvent contains:
| Field | Type | Description |
|---|---|---|
type | "kv_update" | Always "kv_update" |
key | string | The key that changed |
value | string | The new value as base64-encoded bytes (empty for deletes) |
revision | number | New revision number |
operation | "put" | "delete" | What happened |
bucket | string | Bucket name |
event.value is the raw stored bytes encoded as base64 — not a parsed JSON object. If you stored JSON, decode with JSON.parse(atob(event.value)) before use.
Watch is available in the browser SDK only. For server-side change notifications, use Events & Pub/Sub.
// Watch for changes on a keywatcher, err := bucket.Watch(ctx, ironflow.KVWatchCallbacks{ OnUpdate: func(event ironflow.KVWatchEvent) { fmt.Printf("Key %s changed: %s\n", event.Key, string(event.Value)) },}, ironflow.WithWatchKey("user:123"))// Stop watching: watcher.Stop()Error Handling
KV operations return standard HTTP error codes for common failure scenarios:
| Error | HTTP Status | When |
|---|---|---|
| Key not found | 404 | Get or delete on a missing key |
| Key already exists | 412 | Create when the key already exists |
| Revision mismatch | 412 | Update with a stale revision |
| Bucket not found | 404 | Operations on a bucket that does not exist |
import { IronflowError, UnauthenticatedError, UnauthorizedError } from "@ironflow/node";
try { await bucket.create("user:123", { name: "Alice" });} catch (err) { if (err instanceof UnauthenticatedError) { console.error("Invalid or missing API key"); } else if (err instanceof UnauthorizedError) { console.error("Insufficient permissions for this operation"); } else if (err instanceof IronflowError) { // IronflowError includes the HTTP status in the message if (err.message.includes("412")) { console.error("Key already exists — use put() to overwrite or update() with a revision"); } else if (err.message.includes("404")) { console.error("Bucket not found — create it first"); } else { console.error("Ironflow error:", err.message); } } else { console.error("Unexpected error:", err); }}Use the global onError handler on createClient() to observe all KV errors centrally without wrapping each call in try/catch.
import { IronflowError } from "@ironflow/core";
try { await bucket.create("user:123", { name: "Alice" });} catch (err) { if (err instanceof IronflowError) { if (err.code === "HTTP_412") { console.error("Key already exists — use put() to overwrite or update() with a revision"); } else if (err.code === "HTTP_404") { console.error("Bucket not found — create it first"); } else { console.error("Ironflow error:", err.message, "Code:", err.code); } } else { console.error("Unexpected error:", err); }}_, err := bucket.Create(ctx, "user:123", []byte(`{"name":"Alice"}`))if err != nil { var ironErr *ironflow.IronflowError if errors.As(err, &ironErr) { switch ironErr.Code { case "HTTP_412": fmt.Println("Key already exists — use Put() to overwrite or Update() with a revision") case "HTTP_404": fmt.Println("Bucket not found — create it first") default: fmt.Printf("Ironflow error: %s (code: %s)\n", ironErr.Message, ironErr.Code) } } else { fmt.Println("Unexpected error:", err) }}