Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:24:21 +08:00
commit a5d916d454
11 changed files with 3951 additions and 0 deletions

View File

@@ -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<string, string | null> = {};
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;

View File

@@ -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<T>(
kv: KVNamespace,
cacheKey: string,
fetchFn: () => Promise<T>,
options: {
ttl?: number; // KV expiration (default: 3600)
cacheTtl?: number; // Edge cache TTL (default: 300)
} = {}
): Promise<T> {
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<T>(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<T>(
kv: KVNamespace,
cacheKey: string,
fetchFn: () => Promise<T>,
ctx: ExecutionContext,
options: {
ttl?: number;
staleThreshold?: number; // Refresh if older than this
} = {}
): Promise<T> {
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<string, { value: any; expires: number }>();
async function getMultiLayerCache<T>(
kv: KVNamespace,
cacheKey: string,
fetchFn: () => Promise<T>,
options: {
ttl?: number;
memoryTtl?: number; // In-memory cache duration
} = {}
): Promise<T> {
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<T>(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;

View File

@@ -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<T>(
kv: KVNamespace,
options: {
prefix?: string;
batchSize?: number;
},
processor: (keys: any[]) => Promise<T[]>
): Promise<T[]> {
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<string, number>();
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<string, string[]>();
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;

View File

@@ -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<VersionedData>(
`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<string, AuditMetadata>(
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<FeatureFlag>();
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<FeatureFlag>(`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;

View File

@@ -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": "<YOUR_PRODUCTION_NAMESPACE_ID>",
// Preview/local namespace ID (from: wrangler kv namespace create CACHE --preview)
// This is optional but recommended for local development
"preview_id": "<YOUR_PREVIEW_NAMESPACE_ID>"
},
// Multiple namespaces example
{
"binding": "CONFIG",
"id": "<CONFIG_PRODUCTION_ID>",
"preview_id": "<CONFIG_PREVIEW_ID>"
},
{
"binding": "SESSIONS",
"id": "<SESSIONS_PRODUCTION_ID>",
"preview_id": "<SESSIONS_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
}