KVStore
Simple key-value storage backed by DynamoDB.
When to use: Fast, single-key lookups with get/put/delete semantics. Good for caches, session stores, feature flags, and config values.
When NOT to use: If you need to query by multiple fields or secondary indexes, use DistributedTable. If you need full SQL, use Database.
Design & mock parity details: DESIGN.md
API
const store = new KVStore(scope, id, options?)
| Method | Returns | Description |
|---|---|---|
get(key) | Promise<T | null> | Retrieve a value. Returns null if absent or expired. |
put(key, value, options?) | Promise<void> | Store a value. Overwrites unless conditions are set; accepts an optional expiry. |
delete(key, conditions?) | Promise<void> | Remove a value. |
scan(options?) | AsyncIterable<{ key, value }> | Enumerate all entries. Skips expired items unless { includeExpired: true }. Expensive on large datasets. |
KVStore.fromExisting(tableName) | ExternalTableRef | Wrap a pre-existing DynamoDB table. |
Runtime only. Data methods (get, put, delete, scan) run at request time — call them inside an ApiNamespace method, RawRoute handler, job handler, or a runtime script, not at the top level of your aws-blocks/index.ts. Top-level code runs during CDK synth, where the block resolves to its infrastructure construct, whose data methods are guards: a top-level call throws an Error reading KVStore.<method>() cannot be called during CDK synth. Data methods run at request time — …. To seed data, do it from inside a handler or a separate runtime script. Constructing the block at module scope is fine; only method calls must move into handlers.
Options
| Option | Type | Description |
|---|---|---|
schema | StandardSchemaV1 | Runtime validation schema (Zod, Valibot, ArkType, etc.). When provided, the value type T is inferred from the schema and every put() validates the value before writing. |
table | ExternalTableRef | Wrap an existing DynamoDB table instead of creating one. |
logger | ChildLogger | Optional logger for internal operations. When omitted, a default Logger at error level is created. |
removalPolicy | 'destroy' | 'retain' | Removal behavior for the underlying DynamoDB table. When omitted, the stack-wide defaults (from BlocksPresets.sandbox/production, chosen at BlocksStack.create) apply — production retains data on cdk destroy, sandbox destroys it. Pass 'destroy'/'retain' to override for this one store. The table's deletion protection also follows the stack defaults. Ignored by the mock and browser runtimes. |
ttl | boolean | Enable DynamoDB Time-to-Live so items written with an expiry are deleted automatically. Defaults to false. See Expiring Items. |
pointInTimeRecovery | boolean | { retentionDays: number } | DynamoDB Point-in-Time Recovery (continuous backups). true enables the default 35-day window; { retentionDays: n } enables PITR and pins the window (1–35); false disables it. When omitted, the stack-wide defaults apply (on under production, off under sandbox). Ignored by the mock and browser runtimes. |
encryption | 'aws-managed' | 'customer-managed' | ExternalKmsKeyRef | Server-side encryption at rest. 'aws-managed' (default) uses the aws/dynamodb key; 'customer-managed' provisions a dedicated CMK per table; pass KVStore.fromKmsKey(arn) to reuse an existing CMK across stores. Ignored by the mock and browser runtimes. |
Expiring Items (TTL)
TTL is opt-in in two steps — turn it on for the table, then set an expiry per write:
const cache = new KVStore(scope, 'cache', { ttl: true });
// Relative expiry — delete 5 minutes from now
await cache.put('otp:alice', code, { ttlSeconds: 300 });
// Absolute expiry — a Date, or Unix epoch time in SECONDS
await cache.put('session:1', record, { expiresAt: new Date('2027-01-01') });
// No expiry — stored indefinitely (the default)
await cache.put('config:theme', 'dark');
ttldefaults tofalse. Enabling it on a table that already exists is a CloudFormation update to the live table, so it never happens implicitly.- The attribute is named
ttl. It is written only whenttlSecondsorexpiresAtis supplied, and it holds Unix epoch seconds. ttlSecondsandexpiresAtare mutually exclusive. Passing both — or a value that looks like epoch milliseconds — throwsValidationFailedExceptionrather than silently storing a year-5138 expiry.- Reads never return expired items. DynamoDB's reaper is asynchronous (typically within 48 hours), so
getreturnsnullandscanskips an item as soon as its expiry passes, in every runtime. - Expiry is per write. Re-putting a key without expiry options clears any previous expiry; re-putting with them slides it forward.
- TTL composes with conditional writes:
put(key, value, { ifNotExists: true, ttlSeconds: 60 }).
TTL is a retention control, not an authorization one. Anything security-sensitive (session validity, token revocation) must still be checked on read.
Conditional Operations
Both put and delete accept an optional options object:
// Only write if key doesn't exist (idempotent create)
await store.put('user:alice', data, { ifNotExists: true });
// Only write if current value matches (optimistic locking / compare-and-swap)
await store.put('counter', newVal, { ifValueEquals: oldVal });
// Compose both (OR): create it, or update it only if unchanged — write succeeds
// if the key is absent OR its current value matches; fails only if it exists and differs.
await store.put('config', next, { ifNotExists: true, ifValueEquals: prev });
// Only delete if key exists
await store.delete('temp', { ifExists: true });
// Only delete if value matches
await store.delete('lock', { ifValueEquals: expectedVal });
All condition failures throw with error.name === KVStoreErrors.ConditionalCheckFailed. They serialize to JSON-RPC 409 (Conflict) over the wire (not 500), so error.status === 409 on the client and isBlocksError still matches by name. A conflict is flagged retriable when a value check participated (a stale-value optimistic-lock conflict — re-read and retry may succeed). For put, ifNotExists and ifValueEquals compose with OR, so a failure whenever ifValueEquals was set — including the combined ifNotExists+ifValueEquals case — is the stale-value case and is retriable; a pure ifNotExists put conflict is not retriable. For delete (independent checks, not OR), a pure ifExists conflict — or a combined ifExists+ifValueEquals conflict — is not retriable; only a value-only ifValueEquals delete conflict is. A blind retry of a non-retriable conflict fails identically.
Error Handling
| Constant | error.name | Thrown when |
|---|---|---|
KVStoreErrors.ConditionalCheckFailed | ConditionalCheckFailedException | An ifNotExists / ifExists / ifValueEquals condition failed. Serializes to HTTP 409 (Conflict); retriable when a value check participated (any put conflict where ifValueEquals was set, since put composes with OR; a value-only ifValueEquals delete conflict) — not retriable for a pure ifNotExists put or any ifExists delete. |
KVStoreErrors.ValidationFailed | ValidationFailedException | A value failed the configured schema validation. |
KVStoreErrors.ItemTooLarge | ItemTooLargeException | The serialized item exceeds the 400 KB DynamoDB per-item size limit. (In the AWS layer, DynamoDB raises a generic ValidationException; KVStore re-maps the size-specific case to this name.) |
import { isBlocksError } from '@aws-blocks/core';
import { KVStoreErrors } from '@aws-blocks/bb-kv-store';
try {
await store.put('key', value, { ifNotExists: true });
} catch (e: unknown) {
if (isBlocksError(e, KVStoreErrors.ConditionalCheckFailed)) {
// key already exists
}
if (isBlocksError(e, KVStoreErrors.ItemTooLarge)) {
// value is too large — consider compressing or splitting
}
throw e;
}
Examples
Basic Usage (string values)
const store = new KVStore(scope, 'cache');
export const api = new ApiNamespace(scope, 'api', (context) => ({
async setGreeting(value: string) {
await store.put('greeting', value);
},
async getGreeting() {
return { value: await store.get('greeting') };
},
}));
Without a schema, the value type defaults to string. To store structured data without runtime validation, pass a type argument — new KVStore<MyType>(scope, id); values are JSON-serialized on write and parsed on read automatically (no manual JSON.stringify needed).
Typed Values with Schema
Use a schema to get type-safe values with runtime validation:
The examples use Zod, but
schemaaccepts any StandardSchemaV1 implementation (Zod, Valibot, ArkType). Install your chosen library, e.g.npm install zod.
import { z } from 'zod';
const sessionSchema = z.object({
userId: z.string(),
expiresAt: z.number(),
});
const sessions = new KVStore(scope, 'sessions', { schema: sessionSchema });
// Type is inferred from the schema — no generic arg needed
await sessions.put(token, { userId: 'alice', expiresAt: Date.now() + 3600000 });
const session = await sessions.get(token); // { userId: string; expiresAt: number } | null
// Invalid data is rejected at runtime
await sessions.put(token, { userId: 123 }); // throws ValidationFailedException
Wrapping an Existing Table
const legacy = new KVStore(scope, 'legacy', {
table: KVStore.fromExisting('my-existing-table'),
});
Best Practices
- Keep keys short and descriptive (e.g.,
user:{id},session:{token}) - Store one logical entity per KVStore instance
- Use a
schemafor structured values — it ensures runtime data matches your types - Use
{ ifNotExists: true }for idempotent creates - Use
{ ifValueEquals }for compare-and-swap when multiple writers are possible scan()returns anAsyncIterable— collect withawait Array.fromAsync(store.scan())orfor await. Preferget(key)overscan()(scans read every item).
Scaling & Cost (AWS)
- Billing: PAY_PER_REQUEST — no provisioned capacity to manage
- Latency: Single-digit ms reads and writes
- Throughput: Scales automatically, no upper limit on table size
- Item size limit: 400 KB per item
- Cost: ~$1.25 per million writes, ~$0.25 per million reads
- Durability: 99.999999999% (11 nines) across 3 AZs
Local Development
Mock data persists to disk at .bb-data/{fullId}/ across dev server restarts. Wipe with rm -rf .bb-data. The mock validates the 400 KB item size limit, schema validation, and conditional check failures, matching AWS behavior.