Initial commit
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user