Initial commit
This commit is contained in:
538
templates/kv-basic-operations.ts
Normal file
538
templates/kv-basic-operations.ts
Normal 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;
|
||||
518
templates/kv-caching-pattern.ts
Normal file
518
templates/kv-caching-pattern.ts
Normal 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;
|
||||
551
templates/kv-list-pagination.ts
Normal file
551
templates/kv-list-pagination.ts
Normal 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;
|
||||
568
templates/kv-metadata-pattern.ts
Normal file
568
templates/kv-metadata-pattern.ts
Normal 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;
|
||||
56
templates/wrangler-kv-config.jsonc
Normal file
56
templates/wrangler-kv-config.jsonc
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user