commit a5d916d454aa1ec4da49e0bd7f4a9e4dfc4a1c17 Author: Zhongwei Li Date: Sun Nov 30 08:24:21 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..7ada0dc --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "cloudflare-kv", + "description": "Store key-value data globally with Cloudflare KVs edge network. Use when: caching API responses, storing configuration, managing user preferences, handling TTL expiration, or troubleshooting KV_ERROR, 429 rate limits, eventual consistency, or cacheTtl errors.", + "version": "1.0.0", + "author": { + "name": "Jeremy Dawes", + "email": "jeremy@jezweb.net" + }, + "skills": [ + "./" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..227bc9b --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# cloudflare-kv + +Store key-value data globally with Cloudflare KVs edge network. Use when: caching API responses, storing configuration, managing user preferences, handling TTL expiration, or troubleshooting KV_ERROR, 429 rate limits, eventual consistency, or cacheTtl errors. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..f58e311 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,429 @@ +--- +name: cloudflare-kv +description: | + Store key-value data globally with Cloudflare KV's edge network. Use when: caching API responses, storing configuration, managing user preferences, handling TTL expiration, or troubleshooting KV_ERROR, 429 rate limits, eventual consistency, or cacheTtl errors. +license: MIT +--- + +# Cloudflare Workers KV + +**Status**: Production Ready ✅ +**Last Updated**: 2025-11-24 +**Dependencies**: cloudflare-worker-base (for Worker setup) +**Latest Versions**: wrangler@4.50.0, @cloudflare/workers-types@4.20251121.0 + +**Recent Updates (2025)**: +- **August 2025**: Architecture redesign (40x performance gain, <5ms p99 latency, hybrid storage with R2) +- **January 2025**: Namespace limit increased (200 → 1,000 namespaces per account for Free and Paid plans) + +--- + +## Quick Start (5 Minutes) + +```bash +# Create namespace +npx wrangler kv namespace create MY_NAMESPACE +# Output: [[kv_namespaces]] binding = "MY_NAMESPACE" id = "" +``` + +**wrangler.jsonc:** +```jsonc +{ + "kv_namespaces": [{ + "binding": "MY_NAMESPACE", // Access as env.MY_NAMESPACE + "id": "", + "preview_id": "" // Optional: local dev + }] +} +``` + +**Basic Usage:** +```typescript +type Bindings = { MY_NAMESPACE: KVNamespace }; + +app.post('/set/:key', async (c) => { + await c.env.MY_NAMESPACE.put(c.req.param('key'), await c.req.text()); + return c.json({ success: true }); +}); + +app.get('/get/:key', async (c) => { + const value = await c.env.MY_NAMESPACE.get(c.req.param('key')); + return value ? c.json({ value }) : c.json({ error: 'Not found' }, 404); +}); +``` + +--- + +## KV API Reference + +### Read Operations + +```typescript +// Get single key +const value = await env.MY_KV.get('key'); // string | null +const data = await env.MY_KV.get('key', { type: 'json' }); // object | null +const buffer = await env.MY_KV.get('key', { type: 'arrayBuffer' }); +const stream = await env.MY_KV.get('key', { type: 'stream' }); + +// Get with cache (minimum 60s) +const value = await env.MY_KV.get('key', { cacheTtl: 300 }); // 5 min edge cache + +// Bulk read (counts as 1 operation) +const values = await env.MY_KV.get(['key1', 'key2']); // Map + +// With metadata +const { value, metadata } = await env.MY_KV.getWithMetadata('key'); +const result = await env.MY_KV.getWithMetadata(['key1', 'key2']); // Bulk with metadata +``` + +### Write Operations + +```typescript +// Basic write (max 1/second per key) +await env.MY_KV.put('key', 'value'); +await env.MY_KV.put('user:123', JSON.stringify({ name: 'John' })); + +// With expiration +await env.MY_KV.put('session', data, { expirationTtl: 3600 }); // 1 hour +await env.MY_KV.put('token', value, { expiration: Math.floor(Date.now()/1000) + 86400 }); + +// With metadata (max 1024 bytes) +await env.MY_KV.put('config', 'dark', { + metadata: { updatedAt: Date.now(), version: 2 } +}); +``` + +**Critical Limits:** +- Key: 512 bytes max +- Value: 25 MiB max +- Metadata: 1024 bytes max +- Write rate: 1/second per key (429 error if exceeded) +- Expiration: 60 seconds minimum + +### List Operations + +```typescript +// List with pagination +const result = await env.MY_KV.list({ prefix: 'user:', limit: 1000, cursor }); +// result: { keys: [], list_complete: boolean, cursor?: string } + +// CRITICAL: Always check list_complete, not keys.length === 0 +let cursor: string | undefined; +do { + const result = await env.MY_KV.list({ prefix: 'user:', cursor }); + processKeys(result.keys); + cursor = result.list_complete ? undefined : result.cursor; +} while (cursor); +``` + +### Delete Operations + +```typescript +// Delete single key +await env.MY_KV.delete('key'); // Always succeeds + +// Bulk delete (CLI only, up to 10,000 keys) +// npx wrangler kv bulk delete --binding=MY_KV keys.json +``` + +--- + +## Advanced Patterns + +### Caching Pattern with CacheTtl + +```typescript +async function getCachedData(kv: KVNamespace, key: string, fetchFn: () => Promise, ttl = 300) { + const cached = await kv.get(key, { type: 'json', cacheTtl: ttl }); + if (cached) return cached; + + const data = await fetchFn(); + await kv.put(key, JSON.stringify(data), { expirationTtl: ttl * 2 }); + return data; +} +``` + +**Guidelines**: Minimum 60s, use for read-heavy workloads (100:1 read/write ratio) + +### Metadata Optimization + +```typescript +// Store small values (<1024 bytes) in metadata to avoid separate get() calls +await env.MY_KV.put('user:123', '', { + metadata: { status: 'active', plan: 'pro', lastSeen: Date.now() } +}); + +// list() returns metadata automatically (no additional get() calls) +const users = await env.MY_KV.list({ prefix: 'user:' }); +users.keys.forEach(({ name, metadata }) => console.log(name, metadata.status)); +``` + +### Key Coalescing + +```typescript +// ❌ Bad: Many cold keys +await kv.put('user:123:name', 'John'); +await kv.put('user:123:email', 'john@example.com'); + +// ✅ Good: Single hot key +await kv.put('user:123', JSON.stringify({ name: 'John', email: 'john@example.com' })); +``` + +**Benefit**: Cold keys benefit from hot key caching, fewer operations +**Trade-off**: Requires read-modify-write for updates + +### Pagination Helper + +```typescript +async function* paginateKV(kv: KVNamespace, options: { prefix?: string } = {}) { + let cursor: string | undefined; + do { + const result = await kv.list({ ...options, cursor }); + yield result.keys; + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); +} + +// Usage +for await (const keys of paginateKV(env.MY_KV, { prefix: 'user:' })) { + processKeys(keys); +} +``` + +### Rate Limit Retry with Exponential Backoff + +```typescript +async function putWithRetry(kv: KVNamespace, key: string, value: string, opts?: KVPutOptions) { + let attempts = 0, delay = 1000; + while (attempts < 5) { + try { + await kv.put(key, value, opts); + return; + } catch (error) { + if ((error as Error).message.includes('429')) { + attempts++; + if (attempts >= 5) throw new Error('Max retry attempts'); + await new Promise(r => setTimeout(r, delay)); + delay *= 2; // Exponential backoff + } else throw error; + } + } +} +``` + +--- + +## Understanding Eventual Consistency + +KV is **eventually consistent** across Cloudflare's global network (Aug 2025 redesign: hybrid storage, <5ms p99 latency): + +**How It Works:** +1. Writes immediately visible in same location +2. Other locations see update within ~60 seconds (or cacheTtl value) +3. Cached reads may return stale data during propagation + +**Example:** +```typescript +// Tokyo: Write +await env.MY_KV.put('counter', '1'); +const value = await env.MY_KV.get('counter'); // "1" ✅ + +// London (within 60s): May be stale ⚠️ +const value2 = await env.MY_KV.get('counter'); // Might be old value + +// After 60+ seconds: Consistent ✅ +``` + +**Use KV for**: Read-heavy workloads (100:1 ratio), config, feature flags, caching, user preferences +**Don't use KV for**: Financial transactions, strong consistency, >1/second writes per key, critical data + +**Need strong consistency?** Use [Durable Objects](https://developers.cloudflare.com/durable-objects/) + +--- + +## Wrangler CLI Essentials + +```bash +# Create namespace +npx wrangler kv namespace create MY_NAMESPACE [--preview] + +# Manage keys +npx wrangler kv key put --binding=MY_KV "key" "value" [--ttl=3600] [--metadata='{}'] +npx wrangler kv key get --binding=MY_KV "key" +npx wrangler kv key list --binding=MY_KV [--prefix="user:"] +npx wrangler kv key delete --binding=MY_KV "key" + +# Bulk operations (up to 10,000 keys) +npx wrangler kv bulk put --binding=MY_KV data.json +npx wrangler kv bulk delete --binding=MY_KV keys.json +``` + +--- + +## Limits & Quotas + +| Feature | Free Plan | Paid Plan | +|---------|-----------|-----------| +| Reads per day | 100,000 | Unlimited | +| Writes per day (different keys) | 1,000 | Unlimited | +| **Writes per key per second** | **1** | **1** | +| Operations per Worker invocation | 1,000 | 1,000 | +| **Namespaces per account** | **1,000** | **1,000** | +| Storage per account | 1 GB | Unlimited | +| Key size | 512 bytes | 512 bytes | +| Metadata size | 1024 bytes | 1024 bytes | +| Value size | 25 MiB | 25 MiB | +| Minimum cacheTtl | 60 seconds | 60 seconds | + +**Critical**: 1 write/second per key (429 if exceeded), bulk operations count as 1 operation, namespace limit increased from 200 → 1,000 (Jan 2025) + +--- + + +## Error Handling + +### 1. Rate Limit (429 Too Many Requests) +**Cause**: Writing to same key >1/second +**Solution**: Use retry with exponential backoff (see Advanced Patterns) + +```typescript +// ❌ Bad +await env.MY_KV.put('counter', '1'); +await env.MY_KV.put('counter', '2'); // 429 error! + +// ✅ Good +await putWithRetry(env.MY_KV, 'counter', '2'); +``` + +### 2. Value Too Large +**Cause**: Value exceeds 25 MiB +**Solution**: Validate size before writing + +```typescript +if (value.length > 25 * 1024 * 1024) throw new Error('Value exceeds 25 MiB'); +``` + +### 3. Metadata Too Large +**Cause**: Metadata exceeds 1024 bytes when serialized +**Solution**: Validate serialized size + +```typescript +const serialized = JSON.stringify(metadata); +if (serialized.length > 1024) throw new Error('Metadata exceeds 1024 bytes'); +``` + +### 4. Invalid CacheTtl +**Cause**: cacheTtl <60 seconds +**Solution**: Use minimum 60 + +```typescript +// ❌ Error +await env.MY_KV.get('key', { cacheTtl: 30 }); + +// ✅ Correct +await env.MY_KV.get('key', { cacheTtl: 60 }); +``` + +--- + +## Critical Rules + +### Always Do ✅ + +1. Use bulk operations when reading multiple keys (counts as 1 operation) +2. Set cacheTtl for frequently-read, infrequently-updated data (min 60s) +3. Store small values (<1024 bytes) in metadata when using `list()` frequently +4. Check `list_complete` when paginating, not `keys.length === 0` +5. Use retry logic with exponential backoff for write operations +6. Validate sizes before writing (key 512B, value 25MiB, metadata 1KB) +7. Coalesce related keys for better caching performance +8. Use KV for read-heavy workloads (100:1 read/write ratio ideal) + +### Never Do ❌ + +1. Never write to same key >1/second (causes 429 rate limit errors) +2. Never assume immediate global consistency (takes ~60 seconds to propagate) +3. Never use KV for atomic operations (use Durable Objects instead) +4. Never set cacheTtl <60 seconds (will fail) +5. Never commit namespace IDs to public repos (use environment variables) +6. Never exceed 1000 operations per invocation (use bulk operations) +7. Never rely on write order (eventual consistency = no guarantees) +8. Never forget to handle null values (`get()` returns `null` if key doesn't exist) + +--- + +## Troubleshooting + +### Issue 1: "429 Too Many Requests" on writes +**Cause**: Writing to same key >1/second +**Solution**: Consolidate writes or use retry with exponential backoff + +```typescript +// ❌ Bad: Rate limit +for (let i = 0; i < 10; i++) await kv.put('counter', String(i)); + +// ✅ Good: Single write +await kv.put('counter', '9'); + +// ✅ Good: Retry with backoff +await putWithRetry(kv, 'counter', String(i)); +``` + +### Issue 2: Stale reads after write +**Cause**: Eventual consistency (~60 seconds propagation) +**Solution**: Accept stale reads, use Durable Objects for strong consistency, or implement app-level cache invalidation + +### Issue 3: "Operations limit exceeded" +**Cause**: >1000 KV operations in single Worker invocation +**Solution**: Use bulk operations + +```typescript +// ❌ Bad: 5000 operations +for (const key of 5000keys) await kv.get(key); + +// ✅ Good: 1 operation +const values = await kv.get(keys); // Bulk read +``` + +### Issue 4: List returns empty but cursor exists +**Cause**: Deleted/expired keys create "tombstones" +**Solution**: Always check `list_complete`, not `keys.length` + +```typescript +// ✅ Correct pagination +let cursor: string | undefined; +do { + const result = await kv.list({ cursor }); + processKeys(result.keys); // Even if empty + cursor = result.list_complete ? undefined : result.cursor; +} while (cursor); +``` + +--- + +## Production Checklist + +- [ ] Environment-specific namespaces configured (`id` vs `preview_id`) +- [ ] Namespace IDs stored in environment variables (not hardcoded) +- [ ] Rate limit retry logic implemented for writes +- [ ] Appropriate `cacheTtl` values set for reads (min 60s) +- [ ] Sizes validated (key 512B, value 25MiB, metadata 1KB) +- [ ] Bulk operations used where possible +- [ ] Pagination with `list_complete` check (not `keys.length`) +- [ ] Error handling for null values +- [ ] Monitoring/alerting for rate limits + +--- + +## Related Documentation + +- [Cloudflare KV Docs](https://developers.cloudflare.com/kv/) +- [KV API Reference](https://developers.cloudflare.com/kv/api/) +- [KV Limits](https://developers.cloudflare.com/kv/platform/limits/) +- [How KV Works](https://developers.cloudflare.com/kv/concepts/how-kv-works/) +- [Wrangler KV Commands](https://developers.cloudflare.com/workers/wrangler/commands/#kv) + +--- + +**Last Updated**: 2025-11-24 +**Package Versions**: wrangler@4.50.0, @cloudflare/workers-types@4.20251121.0 diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..0aaa2a0 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,73 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:jezweb/claude-skills:skills/cloudflare-kv", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "ce8453586663e42be92dc68ac0d9688e1b5f3bca", + "treeHash": "91ed0423c52d84b688f4dbc23f5e35d4906098a8c19ee220485da4e066ebf4aa", + "generatedAt": "2025-11-28T10:18:56.373969Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "cloudflare-kv", + "description": "Store key-value data globally with Cloudflare KVs edge network. Use when: caching API responses, storing configuration, managing user preferences, handling TTL expiration, or troubleshooting KV_ERROR, 429 rate limits, eventual consistency, or cacheTtl errors.", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "d2d5798f1e8aecce1df20b5de0992ccb2c942ff8740492203842fa5296d8c214" + }, + { + "path": "SKILL.md", + "sha256": "0bbb7062c7baae72de902f85520d307f996d65c6a11be9a4b45014b0f3534e17" + }, + { + "path": "references/best-practices.md", + "sha256": "0adc9505c4d050e21db41917daaa7e9bfa37b6c558c0a910056045938aa45f58" + }, + { + "path": "references/workers-api.md", + "sha256": "d3283b7c8395c3eb22936522185aa08ef1948802c3501a1532c68475aa819974" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "858fd909ff72e8b3013fa0de105b29ebee5ae2526c336d950d6880060653f62d" + }, + { + "path": "templates/wrangler-kv-config.jsonc", + "sha256": "52694be4cfd90f6e2d5501d0da0e2910e2b041b5c8b86140e08020e9a6fc5131" + }, + { + "path": "templates/kv-metadata-pattern.ts", + "sha256": "fbb3377064dc79b52a93ebd8ea6fdced88bc179981d62c985c3eb13c1e89c480" + }, + { + "path": "templates/kv-list-pagination.ts", + "sha256": "9efca329c0a35a7b47acd4671245b46191c9413d2d80286408404acdfa226e0a" + }, + { + "path": "templates/kv-basic-operations.ts", + "sha256": "850dd2be4f78ae2fac9057bf44424a7b94202c91ba2b789b73e65c8a3f67f798" + }, + { + "path": "templates/kv-caching-pattern.ts", + "sha256": "1e16eb83f3e355b2e5c5f90ef470d9c7433fde3d1cc1d8d015073491d9fc459f" + } + ], + "dirSha256": "91ed0423c52d84b688f4dbc23f5e35d4906098a8c19ee220485da4e066ebf4aa" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/references/best-practices.md b/references/best-practices.md new file mode 100644 index 0000000..94c7a98 --- /dev/null +++ b/references/best-practices.md @@ -0,0 +1,720 @@ +# Cloudflare Workers KV - Best Practices + +This document contains production-tested best practices for Cloudflare Workers KV. + +--- + +## Table of Contents + +1. [Performance Optimization](#performance-optimization) +2. [Caching Strategies](#caching-strategies) +3. [Key Design](#key-design) +4. [Metadata Usage](#metadata-usage) +5. [Error Handling](#error-handling) +6. [Security](#security) +7. [Cost Optimization](#cost-optimization) +8. [Monitoring & Debugging](#monitoring--debugging) + +--- + +## Performance Optimization + +### 1. Use Bulk Operations + +**❌ Bad: Individual reads** +```typescript +const value1 = await kv.get('key1'); // 1 operation +const value2 = await kv.get('key2'); // 1 operation +const value3 = await kv.get('key3'); // 1 operation +// Total: 3 operations +``` + +**✅ Good: Bulk read** +```typescript +const values = await kv.get(['key1', 'key2', 'key3']); // 1 operation +// Total: 1 operation +``` + +**Benefits:** +- Counts as 1 operation against the 1000/invocation limit +- Faster execution +- Lower latency + +--- + +### 2. Use CacheTtl for Frequently-Read Data + +**❌ Bad: No edge caching** +```typescript +const value = await kv.get('config'); // Fetches from KV every time +``` + +**✅ Good: Edge caching** +```typescript +const value = await kv.get('config', { + cacheTtl: 300, // Cache at edge for 5 minutes +}); +``` + +**Guidelines:** +- Use `cacheTtl` for data that changes infrequently +- Minimum: 60 seconds +- Typical values: + - Configuration: 300-600 seconds (5-10 minutes) + - Static content: 3600+ seconds (1+ hour) + - Frequently changing: 60-120 seconds + +**Trade-off:** Higher `cacheTtl` = faster reads but slower updates propagate + +--- + +### 3. Coalesce Related Keys + +**❌ Bad: Many small keys** +```typescript +await kv.put('user:123:name', 'John'); +await kv.put('user:123:email', 'john@example.com'); +await kv.put('user:123:age', '30'); + +// Reading requires 3 operations +const name = await kv.get('user:123:name'); +const email = await kv.get('user:123:email'); +const age = await kv.get('user:123:age'); +``` + +**✅ Good: Coalesced key** +```typescript +await kv.put('user:123', JSON.stringify({ + name: 'John', + email: 'john@example.com', + age: 30, +})); + +// Reading requires 1 operation +const user = await kv.get('user:123', { type: 'json' }); +``` + +**Benefits:** +- Fewer operations +- Single cache entry +- Faster reads + +**When to use:** +- Related data that's always accessed together +- Data that doesn't update frequently +- Values stay under 25 MiB total + +--- + +### 4. Store Small Values in Metadata + +**❌ Bad: Separate keys for metadata** +```typescript +await kv.put('user:123', 'data'); +await kv.put('user:123:status', 'active'); + +// List requires additional get() calls +const users = await kv.list({ prefix: 'user:' }); +for (const key of users.keys) { + const status = await kv.get(`${key.name}:status`); // Extra operation! +} +``` + +**✅ Good: Metadata pattern** +```typescript +await kv.put('user:123', 'data', { + metadata: { status: 'active', plan: 'pro' }, +}); + +// List includes metadata, no extra get() calls! +const users = await kv.list({ prefix: 'user:' }); +for (const key of users.keys) { + console.log(key.name, key.metadata.status); // No extra operations +} +``` + +**When to use:** +- Values fit in 1024 bytes +- Frequently use `list()` operations +- Need to filter/process many keys + +--- + +## Caching Strategies + +### 1. Cache-Aside Pattern (Read-Through) + +```typescript +async function getCached( + kv: KVNamespace, + key: string, + fetchFn: () => Promise, + ttl = 3600 +): Promise { + // Try cache + const cached = await kv.get(key, { + type: 'json', + cacheTtl: 300, + }); + + if (cached !== null) return cached; + + // Cache miss - fetch and store + const data = await fetchFn(); + await kv.put(key, JSON.stringify(data), { expirationTtl: ttl }); + + return data; +} +``` + +**Use when:** +- Data is expensive to compute/fetch +- Read >> Write ratio +- Acceptable to serve slightly stale data + +--- + +### 2. Write-Through Cache + +```typescript +async function updateCached( + kv: KVNamespace, + key: string, + data: T, + ttl = 3600 +): Promise { + // Update database + await database.update(data); + + // Update cache immediately + await kv.put(key, JSON.stringify(data), { expirationTtl: ttl }); +} +``` + +**Use when:** +- Need cache consistency +- Write operations are infrequent +- Cache must always reflect latest data + +--- + +### 3. Stale-While-Revalidate + +```typescript +async function staleWhileRevalidate( + kv: KVNamespace, + key: string, + fetchFn: () => Promise, + ctx: ExecutionContext, + staleThreshold = 300 +): Promise { + const { value, metadata } = await kv.getWithMetadata( + key, + { type: 'json' } + ); + + if (value !== null && metadata) { + const age = Date.now() - metadata.timestamp; + + // Refresh in background if stale + if (age > staleThreshold * 1000) { + ctx.waitUntil( + (async () => { + const fresh = await fetchFn(); + await kv.put(key, JSON.stringify(fresh), { + metadata: { timestamp: Date.now() }, + }); + })() + ); + } + + return value; + } + + // Cache miss + const data = await fetchFn(); + await kv.put(key, JSON.stringify(data), { + metadata: { timestamp: Date.now() }, + }); + + return data; +} +``` + +**Use when:** +- Fast response time is critical +- Acceptable to serve slightly stale data +- Background refresh is acceptable + +--- + +## Key Design + +### 1. Use Hierarchical Namespaces + +**✅ Good key patterns:** +``` +user:123:profile +user:123:settings +user:123:sessions + +session:abc123:data +session:abc123:metadata + +cache:api:users:list +cache:api:posts:123 +cache:db:query:hash123 +``` + +**Benefits:** +- Easy to filter with `list({ prefix: 'user:123:' })` +- Easy to invalidate groups +- Clear organization + +--- + +### 2. Use Lexicographic Ordering + +Keys are always sorted lexicographically, so design keys to take advantage: + +```typescript +// Date-based keys (ISO format sorts correctly) +'log:2025-10-21:entry1' +'log:2025-10-22:entry1' + +// Numeric IDs (zero-padded) +'user:00000001' +'user:00000123' +'user:00001000' + +// Priority-based (prefix with number) +'task:1:high-priority' +'task:2:medium-priority' +'task:3:low-priority' +``` + +--- + +### 3. Avoid Key Collisions + +**❌ Bad:** +``` +user:123 // User data +user:123:count // Some counter +user:123 // Different data? Collision! +``` + +**✅ Good:** +``` +user:data:123 +user:counter:123 +user:session:123 +``` + +--- + +## Metadata Usage + +### 1. Track Versions + +```typescript +await kv.put('config', JSON.stringify(data), { + metadata: { + version: 2, + updatedAt: Date.now(), + updatedBy: 'admin', + }, +}); +``` + +--- + +### 2. Audit Trails + +```typescript +await kv.put(key, value, { + metadata: { + createdAt: Date.now(), + createdBy: userId, + accessCount: 0, + }, +}); +``` + +--- + +### 3. Feature Flags + +```typescript +// Store flags in metadata for fast list() access +await kv.put(`flag:${name}`, JSON.stringify(config), { + metadata: { + enabled: true, + rolloutPercentage: 50, + }, +}); + +// List all flags without additional get() calls +const flags = await kv.list({ prefix: 'flag:' }); +``` + +--- + +## Error Handling + +### 1. Handle Rate Limits (429) + +```typescript +async function putWithRetry( + kv: KVNamespace, + key: string, + value: string, + maxAttempts = 5 +): Promise { + let attempts = 0; + let delay = 1000; + + while (attempts < maxAttempts) { + try { + await kv.put(key, value); + return; + } catch (error) { + const message = (error as Error).message; + + if (message.includes('429') || message.includes('Too Many Requests')) { + attempts++; + if (attempts >= maxAttempts) { + throw new Error('Max retry attempts reached'); + } + + await new Promise(resolve => setTimeout(resolve, delay)); + delay *= 2; // Exponential backoff + } else { + throw error; + } + } + } +} +``` + +--- + +### 2. Handle Null Values + +```typescript +// ❌ Bad +const value = await kv.get('key'); +console.log(value.toUpperCase()); // Error if key doesn't exist + +// ✅ Good +const value = await kv.get('key'); +if (value !== null) { + console.log(value.toUpperCase()); +} + +// ✅ Good: Default value +const value = await kv.get('key') ?? 'default'; +``` + +--- + +### 3. Validate Input Sizes + +```typescript +function validateKVInput(key: string, value: string, metadata?: any): void { + // Key size + if (new TextEncoder().encode(key).length > 512) { + throw new Error('Key exceeds 512 bytes'); + } + + // Value size + if (new TextEncoder().encode(value).length > 25 * 1024 * 1024) { + throw new Error('Value exceeds 25 MiB'); + } + + // Metadata size + if (metadata) { + const serialized = JSON.stringify(metadata); + if (new TextEncoder().encode(serialized).length > 1024) { + throw new Error('Metadata exceeds 1024 bytes'); + } + } +} +``` + +--- + +## Security + +### 1. Never Commit Namespace IDs + +**❌ Bad:** +```jsonc +{ + "kv_namespaces": [ + { + "binding": "MY_KV", + "id": "abc123def456..." // Hardcoded! + } + ] +} +``` + +**✅ Good:** +```jsonc +{ + "kv_namespaces": [ + { + "binding": "MY_KV", + "id": "${KV_NAMESPACE_ID}" // Environment variable + } + ] +} +``` + +--- + +### 2. Encrypt Sensitive Data + +```typescript +// Encrypt before storing +const encrypted = await encrypt(sensitiveData, encryptionKey); +await kv.put('sensitive:123', encrypted); + +// Decrypt after reading +const encrypted = await kv.get('sensitive:123'); +const decrypted = await decrypt(encrypted, encryptionKey); +``` + +--- + +### 3. Use Separate Namespaces for Environments + +```jsonc +{ + "kv_namespaces": [ + { + "binding": "MY_KV", + "id": "production-namespace-id", + "preview_id": "development-namespace-id" + } + ] +} +``` + +--- + +## Cost Optimization + +### 1. Minimize Write Operations (Free Tier) + +**Free tier limits:** +- 1,000 writes per day +- 100,000 reads per day + +**Strategies:** +- Batch writes when possible +- Use longer TTLs to reduce rewrites +- Cache data in memory if accessed frequently within same invocation + +--- + +### 2. Use Metadata Instead of Separate Keys + +**❌ Expensive: 3 writes, 3 reads** +```typescript +await kv.put('user:123:status', 'active'); +await kv.put('user:123:plan', 'pro'); +await kv.put('user:123:updated', Date.now().toString()); +``` + +**✅ Cheaper: 1 write, 1 read** +```typescript +await kv.put('user:123', '', { + metadata: { status: 'active', plan: 'pro', updated: Date.now() }, +}); +``` + +--- + +### 3. Set Appropriate TTLs + +Longer TTLs = fewer rewrites = lower costs + +```typescript +// ❌ Expensive: Rewrites every minute +await kv.put('cache:data', data, { expirationTtl: 60 }); + +// ✅ Better: Rewrites every hour +await kv.put('cache:data', data, { expirationTtl: 3600 }); +``` + +--- + +## Monitoring & Debugging + +### 1. Track Cache Hit Rates + +```typescript +let stats = { hits: 0, misses: 0 }; + +async function getCached(kv: KVNamespace, key: string): Promise { + const value = await kv.get(key, { type: 'json' }); + + if (value !== null) { + stats.hits++; + } else { + stats.misses++; + } + + return value; +} + +// View stats +app.get('/stats', (c) => { + const total = stats.hits + stats.misses; + const hitRate = total > 0 ? (stats.hits / total) * 100 : 0; + + return c.json({ + hits: stats.hits, + misses: stats.misses, + hitRate: `${hitRate.toFixed(2)}%`, + }); +}); +``` + +--- + +### 2. Log KV Operations + +```typescript +async function loggedGet( + kv: KVNamespace, + key: string +): Promise { + const start = Date.now(); + const value = await kv.get(key, { type: 'json' }); + const duration = Date.now() - start; + + console.log({ + operation: 'get', + key, + found: value !== null, + duration, + }); + + return value; +} +``` + +--- + +### 3. Use Namespace Prefixes for Testing + +```typescript +const namespace = env.ENVIRONMENT === 'production' ? 'prod' : 'test'; + +await kv.put(`${namespace}:user:123`, data); + +// Cleanup test data +if (env.ENVIRONMENT === 'test') { + // Delete all test: keys + let cursor: string | undefined; + do { + const result = await kv.list({ prefix: 'test:', cursor }); + await Promise.all(result.keys.map(k => kv.delete(k.name))); + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); +} +``` + +--- + +## Production Checklist + +Before deploying to production: + +- [ ] Environment-specific namespaces configured +- [ ] Namespace IDs stored in environment variables +- [ ] Rate limit retry logic implemented +- [ ] Appropriate `cacheTtl` values set +- [ ] Input validation for key/value/metadata sizes +- [ ] Bulk operations used where possible +- [ ] Pagination implemented correctly for `list()` +- [ ] Error handling for null values +- [ ] Monitoring/alerting for rate limits +- [ ] Documentation for eventual consistency behavior +- [ ] Security review for sensitive data +- [ ] Cost analysis for expected usage + +--- + +## Common Patterns + +### 1. Session Management + +```typescript +// Store session +await kv.put(`session:${sessionId}`, JSON.stringify(sessionData), { + expirationTtl: 3600, // 1 hour + metadata: { userId, createdAt: Date.now() }, +}); + +// Read session +const session = await kv.get(`session:${sessionId}`, { + type: 'json', + cacheTtl: 60, // Cache for 1 minute +}); +``` + +--- + +### 2. API Response Caching + +```typescript +const cacheKey = `api:${endpoint}:${JSON.stringify(params)}`; + +let response = await kv.get(cacheKey, { + type: 'json', + cacheTtl: 300, +}); + +if (!response) { + response = await fetchFromAPI(endpoint, params); + await kv.put(cacheKey, JSON.stringify(response), { + expirationTtl: 600, + }); +} + +return response; +``` + +--- + +### 3. Configuration Management + +```typescript +// Update config +await kv.put('config:app', JSON.stringify(config), { + metadata: { + version: 2, + updatedAt: Date.now(), + updatedBy: adminId, + }, +}); + +// Read config (with long cache) +const config = await kv.get('config:app', { + type: 'json', + cacheTtl: 3600, // Cache for 1 hour +}); +``` + +--- + +## References + +- [Cloudflare KV Documentation](https://developers.cloudflare.com/kv/) +- [How KV Works](https://developers.cloudflare.com/kv/concepts/how-kv-works/) +- [KV Limits](https://developers.cloudflare.com/kv/platform/limits/) +- [KV Pricing](https://developers.cloudflare.com/kv/platform/pricing/) diff --git a/references/workers-api.md b/references/workers-api.md new file mode 100644 index 0000000..96a9b41 --- /dev/null +++ b/references/workers-api.md @@ -0,0 +1,483 @@ +# Cloudflare Workers KV - Complete API Reference + +This document provides the complete Workers KV API reference based on official Cloudflare documentation. + +--- + +## KVNamespace Interface + +```typescript +interface KVNamespace { + get(key: string, options?: Partial>): Promise; + get(key: string, type: "text"): Promise; + get(key: string, type: "json"): Promise; + get(key: string, type: "arrayBuffer"): Promise; + get(key: string, type: "stream"): Promise; + get(keys: string[]): Promise>; + get(keys: string[], type: "json"): Promise>; + + getWithMetadata(key: string, options?: Partial>): Promise>; + getWithMetadata(key: string, type: "json"): Promise>; + getWithMetadata(keys: string[]): Promise>>; + + put(key: string, value: string | ArrayBuffer | ArrayBufferView | ReadableStream, options?: KVPutOptions): Promise; + + delete(key: string): Promise; + + list(options?: KVListOptions): Promise>; +} +``` + +--- + +## Read Operations + +### `get()` - Single Key + +Read a single key-value pair. + +**Signature:** +```typescript +get(key: string, options?: KVGetOptions): Promise +``` + +**Parameters:** +- `key` (string, required) - The key to read +- `options` (object, optional): + - `type` - Return type: `"text"` (default), `"json"`, `"arrayBuffer"`, `"stream"` + - `cacheTtl` (number) - Edge cache duration in seconds (minimum: 60) + +**Returns:** +- `Promise` - Value or `null` if key doesn't exist + +**Examples:** +```typescript +// Text (default) +const value = await env.MY_KV.get('my-key'); + +// JSON +const data = await env.MY_KV.get('my-key', { type: 'json' }); + +// With cache optimization +const value = await env.MY_KV.get('my-key', { + type: 'text', + cacheTtl: 300, // Cache for 5 minutes +}); + +// ArrayBuffer +const buffer = await env.MY_KV.get('binary-key', { type: 'arrayBuffer' }); + +// Stream (for large values) +const stream = await env.MY_KV.get('large-file', { type: 'stream' }); +``` + +--- + +### `get()` - Multiple Keys (Bulk) + +Read multiple keys in a single operation. + +**Signature:** +```typescript +get(keys: string[], type?: 'text' | 'json'): Promise> +``` + +**Parameters:** +- `keys` (string[], required) - Array of keys to read +- `type` (optional) - Return type: `"text"` (default) or `"json"` + +**Returns:** +- `Promise>` - Map of key-value pairs + +**Important:** +- Counts as **1 operation** regardless of number of keys +- Only supports `text` and `json` types (not `arrayBuffer` or `stream`) +- For binary/stream types, use individual `get()` calls with `Promise.all()` + +**Examples:** +```typescript +// Read multiple keys +const keys = ['key1', 'key2', 'key3']; +const values = await env.MY_KV.get(keys); + +// Access values +const value1 = values.get('key1'); +const value2 = values.get('key2'); + +// Convert to object +const obj = Object.fromEntries(values); + +// Read as JSON +const values = await env.MY_KV.get(keys, 'json'); +``` + +--- + +### `getWithMetadata()` - Single Key + +Read key-value pair with metadata. + +**Signature:** +```typescript +getWithMetadata( + key: string, + options?: KVGetOptions +): Promise> +``` + +**Parameters:** +- Same as `get()` + +**Returns:** +```typescript +{ + value: Value | null, + metadata: Metadata | null +} +``` + +**Examples:** +```typescript +// Get with metadata +const { value, metadata } = await env.MY_KV.getWithMetadata('my-key'); + +// Get as JSON with metadata +const { value, metadata } = await env.MY_KV.getWithMetadata('my-key', { + type: 'json', + cacheTtl: 300, +}); + +if (value !== null) { + console.log('Value:', value); + console.log('Metadata:', metadata); +} +``` + +--- + +### `getWithMetadata()` - Multiple Keys (Bulk) + +Read multiple keys with metadata. + +**Signature:** +```typescript +getWithMetadata( + keys: string[], + type?: 'text' | 'json' +): Promise>> +``` + +**Examples:** +```typescript +const keys = ['key1', 'key2']; +const results = await env.MY_KV.getWithMetadata(keys); + +for (const [key, data] of results) { + console.log(key, data.value, data.metadata); +} +``` + +--- + +## Write Operations + +### `put()` - Write Key-Value Pair + +Write or update a key-value pair. + +**Signature:** +```typescript +put( + key: string, + value: string | ArrayBuffer | ArrayBufferView | ReadableStream, + options?: KVPutOptions +): Promise +``` + +**Parameters:** +- `key` (string, required) - Maximum 512 bytes +- `value` (required) - Maximum 25 MiB +- `options` (object, optional): + - `expiration` (number) - Absolute expiration time (seconds since epoch) + - `expirationTtl` (number) - TTL in seconds from now (minimum: 60) + - `metadata` (any) - JSON-serializable metadata (maximum: 1024 bytes) + +**Returns:** +- `Promise` + +**Examples:** +```typescript +// Simple write +await env.MY_KV.put('key', 'value'); + +// Write JSON +await env.MY_KV.put('user:123', JSON.stringify({ name: 'John' })); + +// Write with TTL +await env.MY_KV.put('session', sessionData, { + expirationTtl: 3600, // Expire in 1 hour +}); + +// Write with absolute expiration +const expirationTime = Math.floor(Date.now() / 1000) + 86400; // 24 hours +await env.MY_KV.put('token', tokenValue, { + expiration: expirationTime, +}); + +// Write with metadata +await env.MY_KV.put('config', configData, { + metadata: { + updatedAt: Date.now(), + updatedBy: 'admin', + version: 2, + }, +}); + +// Write with everything +await env.MY_KV.put('key', 'value', { + expirationTtl: 600, + metadata: { source: 'api' }, +}); +``` + +**Limits:** +- **Key size**: Maximum 512 bytes +- **Value size**: Maximum 25 MiB +- **Metadata size**: Maximum 1024 bytes (JSON serialized) +- **Write rate**: Maximum **1 write per second per key** +- **Expiration minimum**: 60 seconds + +--- + +## Delete Operations + +### `delete()` - Delete Key + +Delete a key-value pair. + +**Signature:** +```typescript +delete(key: string): Promise +``` + +**Parameters:** +- `key` (string, required) - Key to delete + +**Returns:** +- `Promise` - Always succeeds, even if key doesn't exist + +**Examples:** +```typescript +// Delete single key +await env.MY_KV.delete('my-key'); + +// Delete multiple keys +const keys = ['key1', 'key2', 'key3']; +await Promise.all(keys.map(key => env.MY_KV.delete(key))); +``` + +**Note:** For bulk delete of >10,000 keys, use the REST API. + +--- + +## List Operations + +### `list()` - List Keys + +List keys in the namespace. + +**Signature:** +```typescript +list(options?: KVListOptions): Promise> +``` + +**Parameters:** +```typescript +interface KVListOptions { + prefix?: string; // Filter keys by prefix + limit?: number; // Max keys to return (default: 1000, max: 1000) + cursor?: string; // Pagination cursor +} +``` + +**Returns:** +```typescript +interface KVListResult { + keys: { + name: string; + expiration?: number; // Seconds since epoch + metadata?: Metadata; + }[]; + list_complete: boolean; // true if no more keys + cursor?: string; // Use for next page +} +``` + +**Examples:** +```typescript +// List all keys (up to 1000) +const result = await env.MY_KV.list(); + +// List with prefix +const users = await env.MY_KV.list({ prefix: 'user:' }); + +// List with limit +const recent = await env.MY_KV.list({ limit: 100 }); + +// Pagination +let cursor: string | undefined; +do { + const result = await env.MY_KV.list({ cursor }); + + // Process keys + console.log(result.keys); + + cursor = result.list_complete ? undefined : result.cursor; +} while (cursor); +``` + +**Important:** +- Keys are **always** sorted lexicographically (UTF-8) +- **Always check `list_complete`**, not `keys.length === 0` +- Empty `keys` array doesn't mean no more data (tombstones exist) +- When paginating with `prefix`, pass `prefix` with each cursor request + +--- + +## Type Definitions + +### KVGetOptions + +```typescript +interface KVGetOptions { + type: Type; // "text" | "json" | "arrayBuffer" | "stream" + cacheTtl?: number; // Edge cache duration (minimum: 60 seconds) +} +``` + +### KVPutOptions + +```typescript +interface KVPutOptions { + expiration?: number; // Seconds since epoch + expirationTtl?: number; // Seconds from now (minimum: 60) + metadata?: any; // Max 1024 bytes serialized +} +``` + +### KVGetWithMetadataResult + +```typescript +interface KVGetWithMetadataResult { + value: Value | null; + metadata: Metadata | null; +} +``` + +### KVListOptions + +```typescript +interface KVListOptions { + prefix?: string; + limit?: number; // Default: 1000, max: 1000 + cursor?: string; +} +``` + +### KVListResult + +```typescript +interface KVListResult { + keys: { + name: string; + expiration?: number; + metadata?: Metadata; + }[]; + list_complete: boolean; + cursor?: string; +} +``` + +--- + +## Limits + +| Feature | Limit | +|---------|-------| +| Key size | 512 bytes | +| Value size | 25 MiB | +| Metadata size | 1024 bytes (JSON) | +| Writes per key per second | 1 | +| Operations per Worker invocation | 1,000 | +| List limit | 1,000 keys | +| Minimum cacheTtl | 60 seconds | +| Minimum expiration | 60 seconds | +| Namespaces per account (Free) | 1,000 | +| Namespaces per account (Paid) | 1,000 | +| Storage per account (Free) | 1 GB | +| Storage per account (Paid) | Unlimited | +| Read operations per day (Free) | 100,000 | +| Read operations per day (Paid) | Unlimited | +| Write operations per day (Free) | 1,000 | +| Write operations per day (Paid) | Unlimited | + +--- + +## Consistency Model + +### Eventually Consistent + +- Writes are **immediately visible** in the same location +- Writes take **up to 60 seconds** to propagate globally +- Cached reads may return stale data during propagation + +### Implications + +```typescript +// Tokyo datacenter +await env.KV.put('counter', '1'); +const value1 = await env.KV.get('counter'); // "1" ✅ + +// London datacenter (within 60 seconds) +const value2 = await env.KV.get('counter'); // Might be old value ⚠️ + +// After 60+ seconds globally +const value3 = await env.KV.get('counter'); // "1" ✅ +``` + +**For strong consistency, use [Durable Objects](https://developers.cloudflare.com/durable-objects/).** + +--- + +## Error Handling + +### Common Errors + +1. **429 Too Many Requests** + - Cause: >1 write/second to same key + - Solution: Implement retry with exponential backoff + +2. **Value too large** + - Cause: Value >25 MiB + - Solution: Validate size before writing + +3. **Metadata too large** + - Cause: Metadata >1024 bytes serialized + - Solution: Validate JSON size before writing + +4. **Invalid cacheTtl** + - Cause: cacheTtl <60 seconds + - Solution: Use minimum 60 seconds + +5. **Operations limit exceeded** + - Cause: >1000 KV operations in Worker invocation + - Solution: Use bulk operations + +--- + +## References + +- [Official KV Documentation](https://developers.cloudflare.com/kv/) +- [KV API Reference](https://developers.cloudflare.com/kv/api/) +- [KV Limits](https://developers.cloudflare.com/kv/platform/limits/) +- [How KV Works](https://developers.cloudflare.com/kv/concepts/how-kv-works/) diff --git a/templates/kv-basic-operations.ts b/templates/kv-basic-operations.ts new file mode 100644 index 0000000..4ecdbf6 --- /dev/null +++ b/templates/kv-basic-operations.ts @@ -0,0 +1,538 @@ +/** + * Cloudflare Workers KV - Basic CRUD Operations + * + * This template demonstrates all basic KV operations: + * - Create (PUT) + * - Read (GET) + * - Update (PUT) + * - Delete (DELETE) + * - List keys + * - Metadata handling + * - TTL/Expiration + * - Error handling + */ + +import { Hono } from 'hono'; + +type Bindings = { + MY_KV: KVNamespace; +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +// ============================================================================ +// CREATE / UPDATE - Write key-value pairs +// ============================================================================ + +// Simple write +app.put('/kv/:key', async (c) => { + const key = c.req.param('key'); + const value = await c.req.text(); + + try { + await c.env.MY_KV.put(key, value); + + return c.json({ + success: true, + message: `Key "${key}" created/updated`, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Write with TTL expiration +app.put('/kv/:key/ttl/:seconds', async (c) => { + const key = c.req.param('key'); + const ttl = parseInt(c.req.param('seconds'), 10); + const value = await c.req.text(); + + // Validate TTL (minimum 60 seconds) + if (ttl < 60) { + return c.json( + { + success: false, + error: 'TTL must be at least 60 seconds', + }, + 400 + ); + } + + try { + await c.env.MY_KV.put(key, value, { + expirationTtl: ttl, + }); + + return c.json({ + success: true, + message: `Key "${key}" will expire in ${ttl} seconds`, + expiresAt: new Date(Date.now() + ttl * 1000).toISOString(), + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Write with metadata +app.put('/kv/:key/metadata', async (c) => { + const key = c.req.param('key'); + const body = await c.req.json<{ value: string; metadata: any }>(); + + // Validate metadata size (max 1024 bytes serialized) + const metadataJson = JSON.stringify(body.metadata); + if (metadataJson.length > 1024) { + return c.json( + { + success: false, + error: `Metadata too large: ${metadataJson.length} bytes (max 1024)`, + }, + 400 + ); + } + + try { + await c.env.MY_KV.put(key, body.value, { + metadata: body.metadata, + }); + + return c.json({ + success: true, + message: `Key "${key}" created with metadata`, + metadata: body.metadata, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Write JSON data +app.post('/kv/json/:key', async (c) => { + const key = c.req.param('key'); + const data = await c.req.json(); + + try { + await c.env.MY_KV.put(key, JSON.stringify(data)); + + return c.json({ + success: true, + message: `JSON data stored at key "${key}"`, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// READ - Get key-value pairs +// ============================================================================ + +// Simple read (text) +app.get('/kv/:key', async (c) => { + const key = c.req.param('key'); + + try { + const value = await c.env.MY_KV.get(key); + + if (value === null) { + return c.json( + { + success: false, + error: `Key "${key}" not found`, + }, + 404 + ); + } + + return c.json({ + success: true, + key, + value, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Read JSON data +app.get('/kv/json/:key', async (c) => { + const key = c.req.param('key'); + + try { + const value = await c.env.MY_KV.get(key, { type: 'json' }); + + if (value === null) { + return c.json( + { + success: false, + error: `Key "${key}" not found`, + }, + 404 + ); + } + + return c.json({ + success: true, + key, + value, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Read with metadata +app.get('/kv/:key/metadata', async (c) => { + const key = c.req.param('key'); + + try { + const { value, metadata } = await c.env.MY_KV.getWithMetadata(key); + + if (value === null) { + return c.json( + { + success: false, + error: `Key "${key}" not found`, + }, + 404 + ); + } + + return c.json({ + success: true, + key, + value, + metadata, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Read with cache optimization +app.get('/kv/:key/cached', async (c) => { + const key = c.req.param('key'); + const cacheTtl = parseInt(c.req.query('cacheTtl') || '300', 10); + + // Validate cacheTtl (minimum 60 seconds) + if (cacheTtl < 60) { + return c.json( + { + success: false, + error: 'cacheTtl must be at least 60 seconds', + }, + 400 + ); + } + + try { + const value = await c.env.MY_KV.get(key, { + type: 'text', + cacheTtl, + }); + + if (value === null) { + return c.json( + { + success: false, + error: `Key "${key}" not found`, + }, + 404 + ); + } + + return c.json({ + success: true, + key, + value, + cached: true, + cacheTtl, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Bulk read (multiple keys) +app.post('/kv/bulk/get', async (c) => { + const { keys } = await c.req.json<{ keys: string[] }>(); + + if (!Array.isArray(keys) || keys.length === 0) { + return c.json( + { + success: false, + error: 'keys must be a non-empty array', + }, + 400 + ); + } + + try { + // Bulk read counts as 1 operation! + const values = await c.env.MY_KV.get(keys); + + // Convert Map to object + const result: Record = {}; + for (const [key, value] of values) { + result[key] = value; + } + + return c.json({ + success: true, + count: keys.length, + values: result, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// LIST - List keys with pagination +// ============================================================================ + +// List all keys (with pagination) +app.get('/kv/list', async (c) => { + const prefix = c.req.query('prefix') || ''; + const cursor = c.req.query('cursor'); + const limit = parseInt(c.req.query('limit') || '1000', 10); + + try { + const result = await c.env.MY_KV.list({ + prefix, + limit, + cursor: cursor || undefined, + }); + + return c.json({ + success: true, + keys: result.keys, + count: result.keys.length, + hasMore: !result.list_complete, + cursor: result.cursor, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// List all keys with prefix (fully paginated) +app.get('/kv/list/all/:prefix', async (c) => { + const prefix = c.req.param('prefix'); + let cursor: string | undefined; + const allKeys: any[] = []; + + try { + // Paginate through all keys + do { + const result = await c.env.MY_KV.list({ + prefix, + cursor, + }); + + allKeys.push(...result.keys); + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + + return c.json({ + success: true, + prefix, + keys: allKeys, + totalCount: allKeys.length, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// DELETE - Delete key-value pairs +// ============================================================================ + +// Delete single key +app.delete('/kv/:key', async (c) => { + const key = c.req.param('key'); + + try { + // Delete always succeeds, even if key doesn't exist + await c.env.MY_KV.delete(key); + + return c.json({ + success: true, + message: `Key "${key}" deleted`, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Delete multiple keys +app.post('/kv/bulk/delete', async (c) => { + const { keys } = await c.req.json<{ keys: string[] }>(); + + if (!Array.isArray(keys) || keys.length === 0) { + return c.json( + { + success: false, + error: 'keys must be a non-empty array', + }, + 400 + ); + } + + try { + // Delete all keys in parallel + await Promise.all(keys.map((key) => c.env.MY_KV.delete(key))); + + return c.json({ + success: true, + message: `${keys.length} keys deleted`, + count: keys.length, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// UTILITY - Helper endpoints +// ============================================================================ + +// Check if key exists +app.get('/kv/:key/exists', async (c) => { + const key = c.req.param('key'); + + try { + const value = await c.env.MY_KV.get(key); + + return c.json({ + exists: value !== null, + key, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Get namespace stats +app.get('/kv/stats', async (c) => { + try { + const result = await c.env.MY_KV.list(); + let totalKeys = result.keys.length; + let cursor = result.cursor; + + // Count all keys (with pagination) + while (!result.list_complete && cursor) { + const nextResult = await c.env.MY_KV.list({ cursor }); + totalKeys += nextResult.keys.length; + cursor = nextResult.cursor; + } + + return c.json({ + success: true, + totalKeys, + sample: result.keys.slice(0, 10), // First 10 keys + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Health check +app.get('/health', (c) => { + return c.json({ + status: 'ok', + timestamp: new Date().toISOString(), + }); +}); + +export default app; diff --git a/templates/kv-caching-pattern.ts b/templates/kv-caching-pattern.ts new file mode 100644 index 0000000..0ae507d --- /dev/null +++ b/templates/kv-caching-pattern.ts @@ -0,0 +1,518 @@ +/** + * Cloudflare Workers KV - Caching Pattern + * + * This template demonstrates optimal caching patterns with KV: + * - Cache-aside pattern (read-through cache) + * - Write-through cache + * - CacheTtl optimization for edge caching + * - Stale-while-revalidate pattern + * - Cache invalidation + */ + +import { Hono } from 'hono'; + +type Bindings = { + CACHE: KVNamespace; + DB: D1Database; // Example: database for cache misses +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +// ============================================================================ +// Cache-Aside Pattern (Read-Through Cache) +// ============================================================================ + +/** + * Generic cache-aside helper + * + * 1. Try to read from cache + * 2. On miss, fetch from source + * 3. Store in cache + * 4. Return data + */ +async function getCached( + kv: KVNamespace, + cacheKey: string, + fetchFn: () => Promise, + options: { + ttl?: number; // KV expiration (default: 3600) + cacheTtl?: number; // Edge cache TTL (default: 300) + } = {} +): Promise { + const ttl = options.ttl ?? 3600; // 1 hour default + const cacheTtl = options.cacheTtl ?? 300; // 5 minutes default + + // Try cache first (with edge caching) + const cached = await kv.get(cacheKey, { + type: 'json', + cacheTtl: Math.max(cacheTtl, 60), // Minimum 60 seconds + }); + + if (cached !== null) { + return cached; + } + + // Cache miss - fetch from source + const data = await fetchFn(); + + // Store in cache (fire-and-forget) + await kv.put(cacheKey, JSON.stringify(data), { + expirationTtl: Math.max(ttl, 60), // Minimum 60 seconds + }); + + return data; +} + +// Example: Cache API response +app.get('/api/user/:id', async (c) => { + const userId = c.req.param('id'); + const cacheKey = `user:${userId}`; + + try { + const user = await getCached( + c.env.CACHE, + cacheKey, + async () => { + // Simulate database fetch + const result = await c.env.DB.prepare( + 'SELECT * FROM users WHERE id = ?' + ) + .bind(userId) + .first(); + + if (!result) { + throw new Error('User not found'); + } + + return result; + }, + { + ttl: 3600, // Cache in KV for 1 hour + cacheTtl: 300, // Cache at edge for 5 minutes + } + ); + + return c.json({ + success: true, + user, + cached: true, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Write-Through Cache Pattern +// ============================================================================ + +/** + * Write-through cache: Update cache when data changes + */ +app.put('/api/user/:id', async (c) => { + const userId = c.req.param('id'); + const userData = await c.req.json(); + const cacheKey = `user:${userId}`; + + try { + // Update database + await c.env.DB.prepare( + 'UPDATE users SET name = ?, email = ? WHERE id = ?' + ) + .bind(userData.name, userData.email, userId) + .run(); + + // Update cache immediately + await c.env.CACHE.put(cacheKey, JSON.stringify(userData), { + expirationTtl: 3600, + }); + + return c.json({ + success: true, + message: 'User updated and cache refreshed', + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Cache Invalidation +// ============================================================================ + +/** + * Invalidate cache when data changes + */ +app.delete('/api/user/:id', async (c) => { + const userId = c.req.param('id'); + const cacheKey = `user:${userId}`; + + try { + // Delete from database + await c.env.DB.prepare('DELETE FROM users WHERE id = ?').bind(userId).run(); + + // Invalidate cache + await c.env.CACHE.delete(cacheKey); + + return c.json({ + success: true, + message: 'User deleted and cache invalidated', + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Invalidate multiple cache keys +app.post('/api/cache/invalidate', async (c) => { + const { keys } = await c.req.json<{ keys: string[] }>(); + + try { + // Delete all cache keys in parallel + await Promise.all(keys.map((key) => c.env.CACHE.delete(key))); + + return c.json({ + success: true, + message: `${keys.length} cache keys invalidated`, + count: keys.length, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Invalidate by prefix (requires list + delete) +app.post('/api/cache/invalidate/prefix', async (c) => { + const { prefix } = await c.req.json<{ prefix: string }>(); + + try { + let cursor: string | undefined; + let deletedCount = 0; + + // List all keys with prefix and delete them + do { + const result = await c.env.CACHE.list({ prefix, cursor }); + + // Delete batch in parallel + await Promise.all(result.keys.map((key) => c.env.CACHE.delete(key.name))); + + deletedCount += result.keys.length; + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + + return c.json({ + success: true, + message: `Cache invalidated for prefix "${prefix}"`, + deletedCount, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Stale-While-Revalidate Pattern +// ============================================================================ + +/** + * Return cached data immediately, refresh in background + */ +async function staleWhileRevalidate( + kv: KVNamespace, + cacheKey: string, + fetchFn: () => Promise, + ctx: ExecutionContext, + options: { + ttl?: number; + staleThreshold?: number; // Refresh if older than this + } = {} +): Promise { + const ttl = options.ttl ?? 3600; + const staleThreshold = options.staleThreshold ?? 300; // 5 minutes + + // Get cached value with metadata + const { value, metadata } = await kv.getWithMetadata< + T, + { timestamp: number } + >(cacheKey, { type: 'json' }); + + // If cached and not too stale, return immediately + if (value !== null && metadata) { + const age = Date.now() - metadata.timestamp; + + // If stale, refresh in background + if (age > staleThreshold * 1000) { + ctx.waitUntil( + (async () => { + try { + const fresh = await fetchFn(); + await kv.put(cacheKey, JSON.stringify(fresh), { + expirationTtl: ttl, + metadata: { timestamp: Date.now() }, + }); + } catch (error) { + console.error('Background refresh failed:', error); + } + })() + ); + } + + return value; + } + + // Cache miss - fetch and store + const data = await fetchFn(); + await kv.put(cacheKey, JSON.stringify(data), { + expirationTtl: ttl, + metadata: { timestamp: Date.now() }, + }); + + return data; +} + +// Example usage +app.get('/api/stats', async (c) => { + try { + const stats = await staleWhileRevalidate( + c.env.CACHE, + 'global:stats', + async () => { + // Expensive computation + const result = await c.env.DB.prepare( + 'SELECT COUNT(*) as total FROM users' + ).first(); + return result; + }, + c.executionCtx, + { + ttl: 3600, // Cache for 1 hour + staleThreshold: 300, // Refresh if older than 5 minutes + } + ); + + return c.json({ + success: true, + stats, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Multi-Layer Cache (KV + Memory) +// ============================================================================ + +/** + * Two-tier cache: In-memory cache + KV cache + * Useful for frequently accessed data within same Worker instance + */ +const memoryCache = new Map(); + +async function getMultiLayerCache( + kv: KVNamespace, + cacheKey: string, + fetchFn: () => Promise, + options: { + ttl?: number; + memoryTtl?: number; // In-memory cache duration + } = {} +): Promise { + const ttl = options.ttl ?? 3600; + const memoryTtl = (options.memoryTtl ?? 60) * 1000; // Convert to ms + + // Check memory cache first (fastest) + const memoryCached = memoryCache.get(cacheKey); + if (memoryCached && memoryCached.expires > Date.now()) { + return memoryCached.value; + } + + // Check KV cache (fast, global) + const kvCached = await kv.get(cacheKey, { + type: 'json', + cacheTtl: 300, + }); + + if (kvCached !== null) { + // Store in memory cache + memoryCache.set(cacheKey, { + value: kvCached, + expires: Date.now() + memoryTtl, + }); + return kvCached; + } + + // Cache miss - fetch from source + const data = await fetchFn(); + + // Store in both caches + memoryCache.set(cacheKey, { + value: data, + expires: Date.now() + memoryTtl, + }); + + await kv.put(cacheKey, JSON.stringify(data), { + expirationTtl: ttl, + }); + + return data; +} + +// Example usage +app.get('/api/config', async (c) => { + try { + const config = await getMultiLayerCache( + c.env.CACHE, + 'app:config', + async () => { + // Fetch from database or API + return { + theme: 'dark', + features: ['feature1', 'feature2'], + version: '1.0.0', + }; + }, + { + ttl: 3600, // KV cache: 1 hour + memoryTtl: 60, // Memory cache: 1 minute + } + ); + + return c.json({ + success: true, + config, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Cache Warming +// ============================================================================ + +/** + * Pre-populate cache with frequently accessed data + */ +app.post('/api/cache/warm', async (c) => { + try { + // Example: Warm cache with top 100 users + const topUsers = await c.env.DB.prepare( + 'SELECT * FROM users ORDER BY activity DESC LIMIT 100' + ).all(); + + // Store each user in cache + const promises = topUsers.results.map((user: any) => + c.env.CACHE.put(`user:${user.id}`, JSON.stringify(user), { + expirationTtl: 3600, + }) + ); + + await Promise.all(promises); + + return c.json({ + success: true, + message: `Warmed cache with ${topUsers.results.length} users`, + count: topUsers.results.length, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Cache Statistics +// ============================================================================ + +/** + * Track cache hit/miss rates + */ +let cacheStats = { + hits: 0, + misses: 0, + errors: 0, +}; + +app.get('/api/cache/stats', (c) => { + const total = cacheStats.hits + cacheStats.misses; + const hitRate = total > 0 ? (cacheStats.hits / total) * 100 : 0; + + return c.json({ + success: true, + stats: { + hits: cacheStats.hits, + misses: cacheStats.misses, + errors: cacheStats.errors, + total, + hitRate: `${hitRate.toFixed(2)}%`, + }, + }); +}); + +// Reset stats +app.post('/api/cache/stats/reset', (c) => { + cacheStats = { hits: 0, misses: 0, errors: 0 }; + + return c.json({ + success: true, + message: 'Cache stats reset', + }); +}); + +// Health check +app.get('/health', (c) => { + return c.json({ + status: 'ok', + timestamp: new Date().toISOString(), + }); +}); + +export default app; diff --git a/templates/kv-list-pagination.ts b/templates/kv-list-pagination.ts new file mode 100644 index 0000000..446f45c --- /dev/null +++ b/templates/kv-list-pagination.ts @@ -0,0 +1,551 @@ +/** + * Cloudflare Workers KV - List & Pagination Patterns + * + * This template demonstrates: + * - Basic listing with cursor pagination + * - Prefix filtering + * - Async iterator pattern + * - Batch processing + * - Key search and filtering + */ + +import { Hono } from 'hono'; + +type Bindings = { + MY_KV: KVNamespace; +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +// ============================================================================ +// Basic Pagination +// ============================================================================ + +// List keys with cursor pagination +app.get('/kv/list', async (c) => { + const prefix = c.req.query('prefix') || ''; + const cursor = c.req.query('cursor'); + const limit = parseInt(c.req.query('limit') || '100', 10); + + try { + const result = await c.env.MY_KV.list({ + prefix, + limit: Math.min(limit, 1000), // Max 1000 + cursor: cursor || undefined, + }); + + return c.json({ + success: true, + keys: result.keys.map((k) => ({ + name: k.name, + expiration: k.expiration, + metadata: k.metadata, + })), + count: result.keys.length, + hasMore: !result.list_complete, + nextCursor: result.cursor, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Async Iterator Pattern +// ============================================================================ + +/** + * Async generator for paginating through all keys + */ +async function* paginateKeys( + kv: KVNamespace, + options: { + prefix?: string; + limit?: number; + } = {} +) { + let cursor: string | undefined; + + do { + const result = await kv.list({ + prefix: options.prefix, + limit: options.limit || 1000, + cursor, + }); + + yield result.keys; + + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); +} + +// Get all keys (fully paginated) +app.get('/kv/all', async (c) => { + const prefix = c.req.query('prefix') || ''; + + try { + const allKeys: any[] = []; + + // Use async iterator to get all keys + for await (const batch of paginateKeys(c.env.MY_KV, { prefix })) { + allKeys.push(...batch); + } + + return c.json({ + success: true, + keys: allKeys.map((k) => k.name), + totalCount: allKeys.length, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Prefix Filtering +// ============================================================================ + +// List keys by namespace prefix +app.get('/kv/namespace/:namespace', async (c) => { + const namespace = c.req.param('namespace'); + const cursor = c.req.query('cursor'); + + try { + const result = await c.env.MY_KV.list({ + prefix: `${namespace}:`, // e.g., "user:", "session:", "cache:" + cursor: cursor || undefined, + }); + + return c.json({ + success: true, + namespace, + keys: result.keys, + count: result.keys.length, + hasMore: !result.list_complete, + nextCursor: result.cursor, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Count keys by prefix +app.get('/kv/count/:prefix', async (c) => { + const prefix = c.req.param('prefix'); + + try { + let count = 0; + let cursor: string | undefined; + + do { + const result = await c.env.MY_KV.list({ + prefix, + cursor, + }); + + count += result.keys.length; + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + + return c.json({ + success: true, + prefix, + count, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Batch Processing +// ============================================================================ + +/** + * Process keys in batches + */ +async function processBatches( + kv: KVNamespace, + options: { + prefix?: string; + batchSize?: number; + }, + processor: (keys: any[]) => Promise +): Promise { + const results: T[] = []; + let cursor: string | undefined; + + do { + const result = await kv.list({ + prefix: options.prefix, + limit: options.batchSize || 100, + cursor, + }); + + // Process this batch + const batchResults = await processor(result.keys); + results.push(...batchResults); + + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + + return results; +} + +// Example: Export all keys with values +app.get('/kv/export', async (c) => { + const prefix = c.req.query('prefix') || ''; + + try { + const exported = await processBatches( + c.env.MY_KV, + { prefix, batchSize: 100 }, + async (keys) => { + // Get values for all keys in batch (bulk read) + const keyNames = keys.map((k) => k.name); + const values = await c.env.MY_KV.get(keyNames); + + // Combine keys with values + return keys.map((key) => ({ + key: key.name, + value: values.get(key.name), + metadata: key.metadata, + expiration: key.expiration, + })); + } + ); + + return c.json({ + success: true, + data: exported, + count: exported.length, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Search & Filtering +// ============================================================================ + +// Search keys by pattern (client-side filtering) +app.get('/kv/search', async (c) => { + const query = c.req.query('q') || ''; + const prefix = c.req.query('prefix') || ''; + + if (!query) { + return c.json( + { + success: false, + error: 'Query parameter "q" is required', + }, + 400 + ); + } + + try { + const matches: any[] = []; + let cursor: string | undefined; + + do { + const result = await c.env.MY_KV.list({ + prefix, + cursor, + }); + + // Filter keys that match the search query + const filteredKeys = result.keys.filter((key) => + key.name.toLowerCase().includes(query.toLowerCase()) + ); + + matches.push(...filteredKeys); + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + + return c.json({ + success: true, + query, + matches: matches.map((k) => k.name), + count: matches.length, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Filter by metadata +app.get('/kv/filter/metadata', async (c) => { + const metadataKey = c.req.query('key'); + const metadataValue = c.req.query('value'); + + if (!metadataKey) { + return c.json( + { + success: false, + error: 'Query parameter "key" is required', + }, + 400 + ); + } + + try { + const matches: any[] = []; + let cursor: string | undefined; + + do { + const result = await c.env.MY_KV.list({ cursor }); + + // Filter by metadata + const filteredKeys = result.keys.filter((key) => { + if (!key.metadata) return false; + + return metadataValue + ? key.metadata[metadataKey] === metadataValue + : metadataKey in key.metadata; + }); + + matches.push(...filteredKeys); + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + + return c.json({ + success: true, + matches: matches.map((k) => ({ + name: k.name, + metadata: k.metadata, + })), + count: matches.length, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Cleanup & Maintenance +// ============================================================================ + +// Delete expired keys (manual cleanup) +app.post('/kv/cleanup/expired', async (c) => { + try { + let deletedCount = 0; + let cursor: string | undefined; + + do { + const result = await c.env.MY_KV.list({ cursor }); + + // Filter keys that have expired + const now = Math.floor(Date.now() / 1000); + const expiredKeys = result.keys + .filter((key) => key.expiration && key.expiration < now) + .map((key) => key.name); + + // Delete expired keys + await Promise.all(expiredKeys.map((key) => c.env.MY_KV.delete(key))); + + deletedCount += expiredKeys.length; + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + + return c.json({ + success: true, + message: `Deleted ${deletedCount} expired keys`, + deletedCount, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Delete all keys with prefix (DANGEROUS!) +app.post('/kv/delete/prefix', async (c) => { + const { prefix } = await c.req.json<{ prefix: string }>(); + + if (!prefix) { + return c.json( + { + success: false, + error: 'Prefix is required', + }, + 400 + ); + } + + try { + let deletedCount = 0; + let cursor: string | undefined; + + do { + const result = await c.env.MY_KV.list({ prefix, cursor }); + + // Delete batch + await Promise.all(result.keys.map((key) => c.env.MY_KV.delete(key.name))); + + deletedCount += result.keys.length; + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + + return c.json({ + success: true, + message: `Deleted ${deletedCount} keys with prefix "${prefix}"`, + deletedCount, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Namespace Statistics +// ============================================================================ + +// Get detailed namespace stats +app.get('/kv/stats/detailed', async (c) => { + try { + let totalKeys = 0; + let withMetadata = 0; + let withExpiration = 0; + const prefixes = new Map(); + + let cursor: string | undefined; + + do { + const result = await c.env.MY_KV.list({ cursor }); + + totalKeys += result.keys.length; + + // Analyze keys + for (const key of result.keys) { + if (key.metadata) withMetadata++; + if (key.expiration) withExpiration++; + + // Extract prefix (before first ":") + const prefix = key.name.split(':')[0]; + prefixes.set(prefix, (prefixes.get(prefix) || 0) + 1); + } + + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + + return c.json({ + success: true, + stats: { + totalKeys, + withMetadata, + withExpiration, + prefixCounts: Object.fromEntries(prefixes), + }, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Group keys by prefix +app.get('/kv/groups', async (c) => { + try { + const groups = new Map(); + let cursor: string | undefined; + + do { + const result = await c.env.MY_KV.list({ cursor }); + + for (const key of result.keys) { + const prefix = key.name.split(':')[0]; + + if (!groups.has(prefix)) { + groups.set(prefix, []); + } + + groups.get(prefix)!.push(key.name); + } + + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + + return c.json({ + success: true, + groups: Object.fromEntries(groups), + groupCount: groups.size, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Health check +app.get('/health', (c) => { + return c.json({ + status: 'ok', + timestamp: new Date().toISOString(), + }); +}); + +export default app; diff --git a/templates/kv-metadata-pattern.ts b/templates/kv-metadata-pattern.ts new file mode 100644 index 0000000..958d2f1 --- /dev/null +++ b/templates/kv-metadata-pattern.ts @@ -0,0 +1,568 @@ +/** + * Cloudflare Workers KV - Metadata Patterns + * + * This template demonstrates: + * - Storing data in metadata for list() efficiency + * - Metadata-based filtering + * - Versioning with metadata + * - Audit trails + * - Feature flags with metadata + */ + +import { Hono } from 'hono'; + +type Bindings = { + MY_KV: KVNamespace; +}; + +const app = new Hono<{ Bindings: Bindings }>(); + +// ============================================================================ +// Metadata Optimization Pattern +// ============================================================================ + +/** + * Store small values in metadata to avoid separate get() calls + * Maximum metadata size: 1024 bytes (JSON serialized) + */ + +// ❌ BAD: Requires 2 operations per key +async function getStatusBad(kv: KVNamespace, userId: string) { + const status = await kv.get(`user:${userId}:status`); + const lastSeen = await kv.get(`user:${userId}:lastseen`); + return { status, lastSeen }; +} + +// ✅ GOOD: Single list() operation gets metadata for all users +async function getStatusGood(kv: KVNamespace) { + const users = await kv.list({ prefix: 'user:' }); + + return users.keys.map((key) => ({ + userId: key.name.split(':')[1], + status: key.metadata?.status, + lastSeen: key.metadata?.lastSeen, + })); +} + +// Example: User status with metadata +app.post('/users/:id/status', async (c) => { + const userId = c.req.param('id'); + const { status } = await c.req.json<{ status: string }>(); + + try { + // Store empty value, all data in metadata + await c.env.MY_KV.put(`user:${userId}`, '', { + metadata: { + status, + lastSeen: Date.now(), + plan: 'free', + }, + }); + + return c.json({ + success: true, + message: 'Status updated', + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// List all users with status (no additional get() calls needed!) +app.get('/users/status', async (c) => { + try { + const users = await c.env.MY_KV.list({ prefix: 'user:' }); + + const statuses = users.keys.map((key) => ({ + userId: key.name.split(':')[1], + status: key.metadata?.status || 'unknown', + lastSeen: key.metadata?.lastSeen, + plan: key.metadata?.plan, + })); + + return c.json({ + success: true, + users: statuses, + count: statuses.length, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Versioning with Metadata +// ============================================================================ + +interface VersionedData { + content: any; + version: number; + updatedAt: number; + updatedBy: string; +} + +// Write with versioning +app.put('/config/:key', async (c) => { + const key = c.req.param('key'); + const content = await c.req.json(); + const updatedBy = c.req.header('X-User-ID') || 'system'; + + try { + // Get current version + const existing = await c.env.MY_KV.getWithMetadata< + VersionedData, + { version: number } + >(`config:${key}`, { type: 'json' }); + + const currentVersion = existing.metadata?.version || 0; + const newVersion = currentVersion + 1; + + // Store new version + const data: VersionedData = { + content, + version: newVersion, + updatedAt: Date.now(), + updatedBy, + }; + + await c.env.MY_KV.put(`config:${key}`, JSON.stringify(data), { + metadata: { + version: newVersion, + updatedAt: data.updatedAt, + updatedBy, + }, + }); + + // Store version history (optional) + await c.env.MY_KV.put( + `config:${key}:v${newVersion}`, + JSON.stringify(data), + { + expirationTtl: 86400 * 30, // Keep versions for 30 days + } + ); + + return c.json({ + success: true, + version: newVersion, + previousVersion: currentVersion, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Get config with version info +app.get('/config/:key', async (c) => { + const key = c.req.param('key'); + + try { + const { value, metadata } = await c.env.MY_KV.getWithMetadata< + VersionedData, + { version: number; updatedAt: number; updatedBy: string } + >(`config:${key}`, { type: 'json' }); + + if (!value) { + return c.json( + { + success: false, + error: 'Config not found', + }, + 404 + ); + } + + return c.json({ + success: true, + data: value, + metadata, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Get specific version +app.get('/config/:key/version/:version', async (c) => { + const key = c.req.param('key'); + const version = c.req.param('version'); + + try { + const data = await c.env.MY_KV.get( + `config:${key}:v${version}`, + { type: 'json' } + ); + + if (!data) { + return c.json( + { + success: false, + error: `Version ${version} not found`, + }, + 404 + ); + } + + return c.json({ + success: true, + data, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Audit Trail with Metadata +// ============================================================================ + +interface AuditMetadata { + createdBy: string; + createdAt: number; + updatedBy: string; + updatedAt: number; + accessCount: number; +} + +// Write with audit trail +app.post('/data/:key', async (c) => { + const key = c.req.param('key'); + const value = await c.req.text(); + const userId = c.req.header('X-User-ID') || 'anonymous'; + + try { + // Check if key exists + const existing = await c.env.MY_KV.getWithMetadata( + key + ); + + const metadata: AuditMetadata = existing.metadata + ? { + ...existing.metadata, + updatedBy: userId, + updatedAt: Date.now(), + } + : { + createdBy: userId, + createdAt: Date.now(), + updatedBy: userId, + updatedAt: Date.now(), + accessCount: 0, + }; + + await c.env.MY_KV.put(key, value, { metadata }); + + return c.json({ + success: true, + audit: metadata, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Read with access tracking +app.get('/data/:key', async (c) => { + const key = c.req.param('key'); + + try { + const { value, metadata } = await c.env.MY_KV.getWithMetadata< + string, + AuditMetadata + >(key); + + if (!value) { + return c.json( + { + success: false, + error: 'Key not found', + }, + 404 + ); + } + + // Increment access count (fire-and-forget) + if (metadata) { + c.executionCtx.waitUntil( + c.env.MY_KV.put(key, value, { + metadata: { + ...metadata, + accessCount: metadata.accessCount + 1, + }, + }) + ); + } + + return c.json({ + success: true, + value, + audit: metadata, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Feature Flags with Metadata +// ============================================================================ + +interface FeatureFlag { + enabled: boolean; + rolloutPercentage: number; + targetUsers?: string[]; + metadata: { + createdAt: number; + updatedAt: number; + description: string; + }; +} + +// Create feature flag +app.post('/flags/:name', async (c) => { + const name = c.req.param('name'); + const flag = await c.req.json(); + + try { + await c.env.MY_KV.put(`flag:${name}`, JSON.stringify(flag), { + metadata: { + enabled: flag.enabled, + rolloutPercentage: flag.rolloutPercentage, + updatedAt: Date.now(), + }, + }); + + return c.json({ + success: true, + message: `Feature flag "${name}" created`, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// List all feature flags (metadata only) +app.get('/flags', async (c) => { + try { + const flags = await c.env.MY_KV.list({ prefix: 'flag:' }); + + const flagList = flags.keys.map((key) => ({ + name: key.name.replace('flag:', ''), + enabled: key.metadata?.enabled || false, + rolloutPercentage: key.metadata?.rolloutPercentage || 0, + updatedAt: key.metadata?.updatedAt, + })); + + return c.json({ + success: true, + flags: flagList, + count: flagList.length, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Check feature flag for user +app.get('/flags/:name/check/:userId', async (c) => { + const name = c.req.param('name'); + const userId = c.req.param('userId'); + + try { + const flag = await c.env.MY_KV.get(`flag:${name}`, { + type: 'json', + }); + + if (!flag) { + return c.json( + { + success: false, + error: 'Feature flag not found', + }, + 404 + ); + } + + // Check if enabled + if (!flag.enabled) { + return c.json({ enabled: false, reason: 'Flag disabled globally' }); + } + + // Check target users + if (flag.targetUsers && flag.targetUsers.length > 0) { + const enabled = flag.targetUsers.includes(userId); + return c.json({ + enabled, + reason: enabled ? 'User in target list' : 'User not in target list', + }); + } + + // Check rollout percentage + const userHash = + parseInt(userId.split('').reduce((a, b) => a + b.charCodeAt(0), 0).toString()) % + 100; + const enabled = userHash < flag.rolloutPercentage; + + return c.json({ + enabled, + reason: enabled + ? `User in ${flag.rolloutPercentage}% rollout` + : `User not in ${flag.rolloutPercentage}% rollout`, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// ============================================================================ +// Metadata Size Validation +// ============================================================================ + +// Validate metadata size before writing +app.post('/validate/metadata', async (c) => { + const { metadata } = await c.req.json<{ metadata: any }>(); + + const serialized = JSON.stringify(metadata); + const size = new TextEncoder().encode(serialized).length; + + if (size > 1024) { + return c.json({ + valid: false, + size, + maxSize: 1024, + error: `Metadata too large: ${size} bytes (max 1024)`, + }); + } + + return c.json({ + valid: true, + size, + maxSize: 1024, + }); +}); + +// ============================================================================ +// Metadata Migration +// ============================================================================ + +// Migrate existing keys to add metadata +app.post('/migrate/add-metadata', async (c) => { + const { prefix, metadata } = await c.req.json<{ + prefix: string; + metadata: any; + }>(); + + try { + let migratedCount = 0; + let cursor: string | undefined; + + do { + const result = await c.env.MY_KV.list({ prefix, cursor }); + + // Migrate batch + for (const key of result.keys) { + // Get existing value + const value = await c.env.MY_KV.get(key.name); + + if (value !== null) { + // Re-write with metadata + await c.env.MY_KV.put(key.name, value, { + metadata: { + ...key.metadata, + ...metadata, + migratedAt: Date.now(), + }, + }); + + migratedCount++; + } + } + + cursor = result.list_complete ? undefined : result.cursor; + } while (cursor); + + return c.json({ + success: true, + message: `Migrated ${migratedCount} keys`, + migratedCount, + }); + } catch (error) { + return c.json( + { + success: false, + error: (error as Error).message, + }, + 500 + ); + } +}); + +// Health check +app.get('/health', (c) => { + return c.json({ + status: 'ok', + timestamp: new Date().toISOString(), + }); +}); + +export default app; diff --git a/templates/wrangler-kv-config.jsonc b/templates/wrangler-kv-config.jsonc new file mode 100644 index 0000000..ad5d380 --- /dev/null +++ b/templates/wrangler-kv-config.jsonc @@ -0,0 +1,56 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "my-worker", + "main": "src/index.ts", + "compatibility_date": "2025-10-11", + "observability": { + "enabled": true + }, + + // KV Namespace Bindings + "kv_namespaces": [ + { + // The binding name - accessible as env.CACHE in your Worker + "binding": "CACHE", + + // Production namespace ID (from: wrangler kv namespace create CACHE) + "id": "", + + // Preview/local namespace ID (from: wrangler kv namespace create CACHE --preview) + // This is optional but recommended for local development + "preview_id": "" + }, + + // Multiple namespaces example + { + "binding": "CONFIG", + "id": "", + "preview_id": "" + }, + + { + "binding": "SESSIONS", + "id": "", + "preview_id": "" + } + ] + + // IMPORTANT NOTES: + // + // 1. Create namespaces first: + // npx wrangler kv namespace create CACHE + // npx wrangler kv namespace create CACHE --preview + // + // 2. Copy the IDs from the command output to this file + // + // 3. NEVER commit real namespace IDs to public repos + // Use environment variables for sensitive namespaces: + // "id": "${KV_CACHE_ID}" + // + // 4. preview_id is optional but recommended for local development + // It creates a separate namespace for testing + // + // 5. Binding names must be valid JavaScript identifiers + // Good: CACHE, MY_KV, UserData + // Bad: my-kv, user.data, 123kv +}