569 lines
12 KiB
TypeScript
569 lines
12 KiB
TypeScript
/**
|
|
* 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;
|