Pular para o conteúdo

@buntime/keyval

Este conteúdo não está disponível em sua língua ainda.

Conceptual documentation for the @buntime/keyval client library — Deno KV-like model, key design, concurrency control via versionstamp, multi-tenant modeling patterns. For the server plugin (REST API, SSE, configuration, troubleshooting), see the KeyVal plugin.

The library is an HTTP/SSE client — every modeling decision here is reflected as keys in the plugin’s underlying SQL. None of the constructs below require server-side code: you only design keys and use the client-side Kv.

The most important mental shift: the key structure IS the index. In SQL you declare indexes and the planner decides. In KeyVal, you materialize the index as another key and maintain the sync explicitly.

AspectSQLKeyVal
SchemaRigid (DDL + migration)Implicit in the JSON value
ValidationIn the database (constraints)In the application (Zod, TypeBox)
AccessDeclarative queries (WHERE, JOIN, GROUP BY)Key/prefix access + manual indexes
PerformanceDepends on planner and indexesO(1) per key; predictable
JOINsNativeN queries or denormalization
AggregationCOUNT, SUM, GROUP BYPre-calculated counters (sum/max/min)
Real-timeTriggers + LISTEN/NOTIFY or pollingBuilt-in watch()
ShardingBreaks cross-shard JOINsNatural by prefix

KeyVal works naturally with DDD aggregates: data that belongs together lives in the same prefix (["orders", id], ["orders", id, "items"], …) and disappears together via delete() by prefix.

Good for: sessions, cache, configuration, queues, counters, hierarchical data, real-time, lightweight FTS. Avoid for: complex ad-hoc queries, analytical reports, heavy aggregations, highly relational data — combine with PostgreSQL/ClickHouse for those cases.

A key is an ordered array of parts. The length and types define the hierarchy, ordering, and query granularity.

TypeExampleTypical useLimit per part
string"users", "usr_001"Namespaces, text IDs1024 chars
number123, 2024Numeric IDs, years, scores8 bytes (IEEE 754)
bigint9007199254740993nVery large IDsno fixed limit
booleantrue, falseFlags (rare)1 byte
Uint8Arraynew Uint8Array([1,2,3])Hashes, binary data1024 bytes

Maximum depth: 20 parts per key. Theoretical total size: ~20 KB in the worst case.

Keys are ordered lexicographically byte by byte. For cross-type ordering, the plugin encodes with a type prefix, guaranteeing Uint8Array < string < number < bigint < boolean.

Practical consequence: numbers are not ordered numerically.

await kv.set(["items", 1], "first");
await kv.set(["items", 2], "second");
await kv.set(["items", 10], "tenth");
for await (const e of kv.list(["items"])) console.log(e.key[1]);
// 1, 10, 2 (lexicographic order, not numeric)
SolutionWhen
Zero-padded strings ("0001")Synthetic IDs under your control
ULID / UUIDv7Time-ordered, distributed IDs
Inverted timestamp (MAX - now)Native descending order (newest first) without reverse: true
["users", userId] // User
["users", userId, "profile"] // 1:1 sub-resource
["users", userId, "posts", postId] // 1:N sub-resource
["users", userId, "posts", postId, "comments", commentId] // Deeper nesting
for await (const e of kv.list(["users", userId, "posts"])) { /* ... */ }
await kv.delete(["users", userId]); // cascading delete: removes everything

delete(prefix) is prefix by default — deletes the key and all descendants. Use { exact: true } to delete only the exact key.

PartConventionGoodBad
EntityPlural, snake_caseusers, blog_posts, order_itemsuser, blogPosts, USERS
IDUUID/ULID/UUIDv7crypto.randomUUID(), Bun.randomUUIDv7()Sequential in distributed systems
1:1 sub-resourceSingularprofile, settings, shippingprofiles
1:N sub-resourcePluralposts, commentspost
Index{entity}_by_{field}users_by_email, posts_by_dateidx_users_1
Bad patternWhyAlternative
["org", o, "dept", d, "team", t, "member", u] (>5 levels)Hard to maintain, breaks on refactorSeparate entities with references
["users", email] (mutable field in key)Changing email means recreating everything["users", id] + ["users_by_email", email] → id
["users", "password123", id] (sensitive data)Keys appear in logs and errorsSensitive data goes in the value

Every entry has a versionstamp (UUIDv7-like) that changes with each modification. It is unique, orderable, opaque — you only compare it, never interpret it.

const entry = await kv.get(["docs", id]);
// { key, value, versionstamp: "019234f0-1234-7abc-..." }
Time Process A Process B
1 Reads doc (vs1)
2 Reads doc (vs1)
3 Modifies + saves
4 Modifies + saves ← A's change is lost

atomic().check(entry) validates that the key’s versionstamp is still the expected one at commit time. If it changed, the commit fails ({ ok: false }) without applying anything.

// Safe update
const entry = await kv.get<User>(["users", id]);
const result = await kv.atomic()
.check(entry)
.set(["users", id], { ...entry.value!, name: "New name" })
.commit();
if (!result.ok) { /* conflict */ }
// Create-if-not-exists (versionstamp: null = "does not exist")
await kv.atomic()
.check({ key: ["users_by_email", email], versionstamp: null })
.set(["users", id], user)
.set(["users_by_email", email], id)
.commit();
UseDo not use
Read-modify-write (any read followed by write)Idempotent operations (intentional overwrite)
Guaranteeing uniqueness on creationCounters — prefer sum(), max(), min()
Updating with secondary indexesOperations without a preceding read
AspectVersionstampDate.now() / kv.now()
Generated byServer (at commit)Client / server
UniquenessGlobal, unique per transactionCan collide under high concurrency
UseConcurrency controlBusiness data (createdAt, updatedAt)
Clock skewN/Akv.now() avoids it; client Date.now() is subject to it

Operations (CRUD, atomic, listing, transactions)

Section titled “Operations (CRUD, atomic, listing, transactions)”

Conceptual overview of the client API. For the corresponding REST API details (same semantics over HTTP), see the KeyVal plugin.

MethodSignatureBehavior
get<T>(key)KvEntry<T>{ key, value, versionstamp } or { value: null, versionstamp: null }
get<T>(keys[])KvEntry<T>[]Batch — one request, same order
set(key, value, opts?)voidUpsert; completely replaces the value; expiresIn for TTL
delete(key, opts?)voidPrefix by default; { exact: true } for a single key; { where: ... } to filter
count(prefix)numberCounts entries; O(n) without an index — for frequent use, maintain an atomic counter

set is always upsert. To create only if absent, use atomic().check({ key, versionstamp: null }).

KeyVal has no native partial update — read-modify-write is the pattern. To do it safely, use transaction() (next section).

atomic() combines checks (expected versionstamps) and mutations in an all-or-nothing commit.

MutationBehavior
set(key, value, opts?)Sets value (with optional TTL)
delete(key)Removes
check(entry | { key, versionstamp })Fails the commit if versionstamp diverges
sum(key, n: bigint)Increments; missing key = 0; bigint
max(key, n: bigint)max(current, new) — useful for highscores, peaks
min(key, n: bigint)min(current, new) — useful for best times
append(key, items[])Concatenates to array; creates if absent
prepend(key, items[])Inserts at the start
// Create user with index and counter, guaranteeing email uniqueness
const result = await kv.atomic()
.check({ key: ["users_by_email", user.email], versionstamp: null })
.set(["users", id], user)
.set(["users_by_email", user.email], id)
.sum(["stats", "users", "total"], 1n)
.commit();
if (!result.ok) throw new Error("Email already registered");

Result: { ok: true, versionstamp } or { ok: false }. A check failure does not throw — you must check result.ok.

Practical limit: ~1,000 operations per commit; recommended < 100. For larger volumes, partition into batches.

MethodReturnWhen to use
list<T>(prefix, opts?)AsyncIterator<KvEntry<T>>Streaming iteration, early exit, sequential processing
paginate<T>(prefix, opts?){ entries, cursor, hasMore }Exposing a cursor to the client (REST, infinite scroll)
search<T>(prefix, query, opts?)AsyncIterator<KvEntry<T>>Full-text search via FTS

list options: limit, reverse, start/end (range), where (server-side filter), cursor.

Cursor vs offset. Always prefer cursor: O(1) per page, consistent under insertions/deletions; offset re-reads and discards data.

Server-side filtering via where. Operators: comparison ($eq, $ne, $gt, $gte, $lt, $lte, $between), arrays ($in, $nin), case-sensitive strings ($contains, $startsWith, $endsWith, $notContains), case-insensitive strings (suffix i: $containsi, …), existence ($null, $empty, $notEmpty), logical ($and, $or, $not). Supports dot-notation ("profile.verified") and array index ("items[0].price").

for await (const e of kv.list<User>(["users"], {
where: {
$and: [
{ age: { $gte: 18 } },
{ $or: [{ status: "active" }, { status: "pending" }] },
{ lastLogin: { $gt: kv.now() - 7 * 24 * 3600_000 } },
],
},
})) { /* ... */ }

transaction(fn, opts?) encapsulates read-modify-write with automatic versionstamp checks on reads and a write buffer. On conflict, it re-executes the function.

Aspectatomic()transaction()
Versionstamp checkManual (explicit .check())Automatic (every read via tx.get is checked)
RetryManualConfigurable via maxRetries (default 0)
ReadsOutside the atomicInside the callback, cached
When to useOperations without a preceding read (create, sum, max)Read-modify-write with logic
const result = await kv.transaction(async (tx) => {
const [from, to] = await tx.get<Account>([["accounts", a], ["accounts", b]]);
if (!from.value || !to.value) throw new Error("Account not found");
if (from.value.balance < amount) throw new Error("Insufficient balance");
tx.set(["accounts", a], { ...from.value, balance: from.value.balance - amount });
tx.set(["accounts", b], { ...to.value, balance: to.value.balance + amount });
}, { maxRetries: 5, retryDelay: "100ms" });

Linear backoff: retryDelay * attempt (50, 100, 150 ms…).

Best practices. Keep transactions short; process iterations outside; ensure idempotency (running the same transaction twice should yield the same result).

Observes keys or prefixes. The callback receives an array of KvEntry when something changes; value: null indicates deletion.

const handle = kv.watch(["users", userId], (entries) => { /* ... */ });
// Default: prefix (key + children). { exact: true } = only the key.
handle.stop();

Modes: SSE (default, low latency) and polling (proxy compatibility). REST endpoint and operational details in the KeyVal plugin.

The only automatic index in KeyVal — maintained by the server in sync with set/delete/atomic. Each prefix supports at most one index; recreating replaces the previous one.

await kv.createIndex(["articles"], { fields: ["title", "content"], tokenize: "porter" });
for await (const e of kv.search<Article>(["articles"], "typescript", {
where: { status: "published" },
limit: 20,
})) { /* ... */ }
TokenizerWhen
unicode61 (default)Multilingual, accented content
porterEnglish with stemming (run/running/runs → run)
asciiIdentifiers, pure ASCII logs, performance

Limitations: only strings are indexed; the index is not retroactive (existing data must be reindexed); advanced FTS5 operators (OR, NOT, NEAR, exact phrases) are not supported — only word-based search with ranking. For full syntax and REST endpoints, see the KeyVal plugin.

FIFO with at-least-once delivery, delay, configurable backoff, and DLQ.

await kv.enqueue(
{ type: "send_email", to: "user@example.com" },
{
delay: 0,
backoffSchedule: [1000, 5000, 30000], // 3 retries
keysIfUndelivered: [["failed", "email-123"]], // fallback after retries exhausted
},
);
kv.listenQueue(async (msg) => {
// msg: { id, value, attempts }
await process(msg.value);
});

Mechanics: enqueuependingdequeue lock → ack (removes) or nack (retry with backoff up to DLQ). An expired lock returns the message to pending. Operational details (DLQ, lock duration, cleanup) in the KeyVal plugin.

await kv.set(["session", id], data, { expiresIn: "7d" });
await kv.set(["cache", key], data, { expiresIn: 300_000 }); // ms also accepted

String formats: ms, s, m, h, d, w, y. After expiration, get() returns null and background cleanup physically removes the entry.

PatternBehaviorUse case
SlidingRe-set on each access renews TTLSession (stays logged in while active)
AbsoluteFixed TTL, does not renewVerification token, reset code

There is no native extend() — use get() + set() with a new TTL.

The highest-value section: how to map real-world domains to keys.

StrategyWhenTrade-off
Embedded in the main documentData always accessed togetherLarger document; naturally atomic updates
Separate key (["users", id, "profile"])Independent accessMultiple calls if both are needed; manual consistency

Two strategies with different semantics:

StrategyStructureWhen
Hierarchical["users", uid, "posts", pid]Child belongs to parent; automatic cascading delete
Reference + index["posts", pid] → { authorId } + ["posts_by_author", uid, pid] → pidIndependent child; global queries; co-authorship

Bidirectional join table:

["posts", pid] → { title, tags }
["tags", tag] → { description, count }
["post_tags", pid, tag] → kv.now()
["tag_posts", tag, pid] → kv.now()

Create/update/remove must maintain both sides in a single atomic (including the counter ["tags", tag, "count"]).

TypeStructureUse case
Unique["users_by_email", email] → idEmail, SSN, username — check({ versionstamp: null }) on creation
Non-unique["users_by_city", city, id] → idCity, status, category — ID in the key to avoid collision
Composite["products_by_cat_price", cat, price, id] → id”Category X ordered by price” query
Temporal["events_by_time", ts, id] → id (or UUIDv7)“Recent events”, logs
Inverted["tags", tag, postId] → postIdN:N (tags)
Prefix["users_by_name", "ali"] → ["usr_001", ...]Autocomplete

Maintenance is the application’s responsibility. Always inside atomic():

// Email update — updates user + removes old index + creates new index
await kv.atomic()
.check(userEntry)
.check({ key: ["users_by_email", newEmail], versionstamp: null })
.set(["users", id], { ...user, email: newEmail })
.delete(["users_by_email", oldEmail])
.set(["users_by_email", newEmail], id)
.commit();

Cost. Each index = +1 mutation per write and +1x storage; reads become O(1) instead of O(n). Rebuilding is possible via list + set in batches when an index becomes inconsistent.

Denormalization in the index (["users_by_city", city, id] → { id, name, email } instead of just id) eliminates the extra lookup when listing — only for data that changes rarely.

PatternTypical structureWhen
Simple entity + audit["users", id] → { id, ..., createdAt, updatedAt }Basic CRUD
Embedded document["orders", id] → { items, shipping, totals }Data always accessed together
Aggregated hierarchy["orgs", oid, "projects", pid, "tasks", tid]Multi-tenant; cascading delete
Atomic counters["stats", "posts", pid, "views"] → bigintHigh-frequency metrics
Pre-computed aggregation["stats", "sales", year, month, "count/total"]Dashboards; no runtime GROUP BY
TTL["session", sid] → data (with expiresIn)Sessions, cache, tokens, distributed locks
Rate limiting["rate_limit", id, window] → count (TTL = window)API protection
Feature flags["features", name] → { enabled, percentage, enabledFor }Gradual rollout, A/B
Audit log (3-way index)["audit", "by_time", ts, id] + ["audit", "by_actor", uid, ts, id] + ["audit", "by_target", type, tid, ts, id]GDPR, debugging, compliance
Workflow / state machine["orders", id] → { status, statusHistory } + ["orders_by_status", st, id]Orders, processes with transitions
Soft deletedeletedAt in the value + parallel ["deleted_users", id]Recovery, compliance

For tenant isolation, prefix all keys:

["t", tenantId, "users", userId]
["t", tenantId, "users_by_email", email]
["t", tenantId, "stats", "users", "total"]

delete(["t", tenantId]) removes everything for the tenant. Watchers and indexes are naturally isolated.

FactorDenormalizeDo not denormalize
Read vs write ratioMany reads, few writesFrequent writes
Change frequencyRarely changing data (author name)Hot data (balance, follower count)
ConsistencyEventual is acceptableStrong consistency required
Update strategyAsync job via enqueue to propagate

Conceptual limitations of the KV approach (not exclusive to the Buntime plugin). For server operational limits (Watch polling, SQLite single writer, missing automatic retry in transactions, BigInt precision), see the Limitations section of the KeyVal plugin.

AreaLimitationMitigation
Ad-hoc queriesNo arbitrary WHERE x AND y AND z ORDER BY wPre-defined indexes for known queries; server-side where for non-hot fields; PostgreSQL for reports
JOINsNo native JOINMultiple batch get calls or controlled denormalization
AggregationsNo GROUP BY/AVG/percentilessum/max/min counters maintained in atomic; analytics database for the rest
Long transactionsHigher conflict probability; prolonged lock in SQLiteKeep short; process iteration outside; ensure idempotency
Key part size1 KB (string/Uint8Array); depth 20 partsValidate in the application; short, focused keys
Operations per atomic~1,000 (recommended < 100)Partition into batches
Value sizeNo hard limit (libSQL/SQLite up to 1 GB)For > 1 MB, external storage (S3/R2) with a reference in KeyVal; chunking only if unavoidable
Numeric keysLexicographic, not numericZero-padding, ULID, UUIDv7, inverted timestamp
FromTo
Redis stringsset(["key"], value)
Redis hashesset(["hash", field], value) or JSON object
Redis sorted sets["zset", score, member] (composite index)
Redis TTLexpiresIn
Redis Pub/Subwatch() + event pattern
MongoDB collectionsPrefix: ["users", id]
MongoDB documentsJSON values
MongoDB indexesManual indexes
DynamoDB PKKey
DynamoDB SKKey part: ["table", pk, sk]
DynamoDB GSIManual secondary indexes
DynamoDB Streamswatch()

KeyVal does not replace RDBMS for everything. Recommended pattern in real-world apps:

// PostgreSQL: relational data and reports
const orders = await db.query(`SELECT * FROM orders WHERE user_id = $1`, [uid]);
// KeyVal: session, cache, queues, real-time
const session = await kv.get(["session", sid]);
await kv.set(["cache", "user", uid], data, { expiresIn: "5m" });
await kv.enqueue({ type: "send_email", uid });
kv.watch(["orders", uid], notifyClient);

For the server side (REST + SSE), endpoints, plugin configuration, troubleshooting, and operational limitations — see the KeyVal plugin.