Het probleem van volharding aan de rand

De V8 isoleert die kracht. Cloudflare Workers zijn van nature staatloos: elk geïsoleerd kan in microseconden worden gemaakt en vernietigd, en de lokale JavaScript-status niet blijft bestaan tussen verzoeken (of blijft slechts af en toe bestaan, vanwege gelijktijdigheid op hetzelfde geïsoleerd). Om echte applicaties te bouwen heb je externe persistentie nodig.

Cloudflare biedt drie native opslagproducten, elk ontworpen voor een gebruiksscenario specifiek. Als u de verkeerde kiest, kan dit langzame zoekopdrachten en hoge kosten met zich meebrengen of inconsistenties die moeilijk te debuggen zijn. Dit artikel vergelijkt ze in detail met Echte codevoorbeelden.

Wat je gaat leren

  • Workers KV: uiteindelijk consistente architectuur, API, limieten en ideale use cases
  • Cloudflare R2: S3-compatibele objectopslag zonder uitgaande kosten, upload in meerdere delen
  • D1 SQLite: relationele database aan de rand, SQL-query's, migraties met Drizzle ORM
  • Kostenvergelijking: KV versus R2 versus D1 bij verschillende gebruiksschalen
  • Integratiepatroon: Hoe KV + D1 te combineren voor intelligente caching
  • Bindingen configureren in wrangler.toml voor elk opslagtype

Werknemers KV: mondiale sleutelwaarde en uiteindelijk consistent

Workers KV is een wereldwijd gedistribueerde sleutel/waarde-datastore. Wanneer je schrijft een waarde wordt binnen enkele seconden gerepliceerd naar alle Cloudflare PoP's. Wanneer je leest, haal waarde uit de dichtstbijzijnde PoP: leeslatentie doorgaans minder dan 1 ms.

De fundamentele afweging: KV is uiteindelijk consistent. Een schrijven het kan tot 60 seconden duren voordat het wereldwijd zichtbaar is. Dit maakt het niet geschikt voor gegevens die vaak veranderen en onmiddellijk moeten worden gelezen na het schrijven.

KV-configuratie in wrangler.toml

# wrangler.toml

[[kv_namespaces]]
binding = "CACHE"          # Nome del binding nel codice TypeScript
id = "xxxxxxxxxxxxxxxxxx"  # ID del namespace in produzione
preview_id = "yyyyyyyyyy"  # ID del namespace per wrangler dev

[[kv_namespaces]]
binding = "SESSIONS"
id = "aaaaaaaaaaaaaaaaa"
preview_id = "bbbbbbbbbbbbb"

# Per creare i namespace:
# wrangler kv namespace create CACHE
# wrangler kv namespace create CACHE --preview

Workers KV API in TypeScript

// src/services/cache.service.ts

export interface Env {
  CACHE: KVNamespace;
  SESSIONS: KVNamespace;
}

// -------- OPERAZIONI BASE --------

// WRITE: put con TTL opzionale (in secondi)
await env.CACHE.put('user:123', JSON.stringify({ name: 'Mario', role: 'admin' }), {
  expirationTtl: 3600,  // scade tra 1 ora
});

// WRITE senza TTL (valore permanente)
await env.CACHE.put('config:features', JSON.stringify({ darkMode: true }));

// WRITE con expiration assoluta (Unix timestamp)
await env.CACHE.put('promo:summer', 'active', {
  expiration: Math.floor(Date.now() / 1000) + 86400,  // scade tra 24h
});

// READ: get con tipo di decodifica
const raw = await env.CACHE.get('user:123');
// raw: string | null

const parsed = await env.CACHE.get<{ name: string; role: string }>('user:123', 'json');
// parsed: { name: string; role: string } | null

// READ con metadata
const { value, metadata } = await env.CACHE.getWithMetadata('user:123', 'json');

// DELETE
await env.CACHE.delete('user:123');

// -------- LISTING (con limitazioni) --------
// KV supporta listing, ma e lento e con limite di 1000 chiavi per chiamata
const listing = await env.CACHE.list({ prefix: 'user:', limit: 100 });
for (const key of listing.keys) {
  console.log(key.name, key.expiration, key.metadata);
}

// -------- PATTERN: cache-aside --------
async function getUserCached(userId: string, env: Env): Promise<User | null> {
  const cacheKey = `user:${userId}`;

  // 1. Controlla la cache KV
  const cached = await env.CACHE.get<User>(cacheKey, 'json');
  if (cached) {
    return cached;  // Cache hit: risposta da KV < 1ms
  }

  // 2. Cache miss: fetch dall'origine
  const user = await fetchUserFromDatabase(userId);
  if (!user) return null;

  // 3. Popola la cache (eventually consistent, ok per profili utente)
  await env.CACHE.put(cacheKey, JSON.stringify(user), {
    expirationTtl: 300,  // 5 minuti
  });

  return user;
}
Kenmerkend KV-detail
Consistentiemodel Uiteindelijk consistent (maximale voortplanting in de jaren 60)
Lees latentie < 1 ms (van lokale PoP na warm)
Schrijf latentie ~100 ms (bevestiging), asynchrone globale replicatie
Maximale waardegrootte 25 MB per waarde
Maximale sleutelgrootte 512 bytes
Kosten lezen $ 0,50 per miljoen (gratis: 10 miljoen/maand)
Kosten schrijven $ 5 per miljoen (gratis: 1 miljoen/maand)
Ideaal voor Configuratie, functievlaggen, sessieopslag, API-caching
Niet geschikt voor Realtime tellers, gegevens die elke seconde veranderen

Cloudflare R2: S3-compatibele objectopslag zonder uitgaande kosten

R2 is het product dat bij de lancering in 2022 het meeste lawaai maakte: objectopslag compatibel met AWS S3 API's, met nul uitgaande kosten. Op S3, Het overbrengen van 1TB aan gegevens naar internet kost ~$90. Op R2 is het gratis.

R2 is ideaal voor: het uploaden van gebruikersbestanden, statische assets, back-ups, gearchiveerde logs, elk binair object dat moet worden opgehaald door browsers of andere werknemers.

R2-configuratie in wrangler.toml

# wrangler.toml

[[r2_buckets]]
binding = "ASSETS"         # Nome del binding TypeScript
bucket_name = "my-assets"  # Nome del bucket in Cloudflare

# Per creare il bucket:
# wrangler r2 bucket create my-assets

R2-API in TypeScript

// src/services/storage.service.ts

export interface Env {
  ASSETS: R2Bucket;
}

// -------- UPLOAD --------

// Upload di testo semplice
await env.ASSETS.put('documents/readme.txt', 'Contenuto del file', {
  httpMetadata: { contentType: 'text/plain; charset=utf-8' },
  customMetadata: { uploadedBy: 'user-123', version: '1' },
});

// Upload di JSON
const data = { users: [{ id: 1, name: 'Mario' }] };
await env.ASSETS.put('data/users.json', JSON.stringify(data), {
  httpMetadata: { contentType: 'application/json' },
});

// Upload di un file binario da una Request (multipart form)
async function handleFileUpload(request: Request, env: Env): Promise<Response> {
  const formData = await request.formData();
  const file = formData.get('file') as File | null;

  if (!file) {
    return new Response('No file provided', { status: 400 });
  }

  // Genera un nome unico per evitare collisioni
  const key = `uploads/${Date.now()}-${file.name}`;

  await env.ASSETS.put(key, file.stream(), {
    httpMetadata: {
      contentType: file.type,
      contentLength: file.size,
    },
    customMetadata: {
      originalName: file.name,
      uploadedAt: new Date().toISOString(),
    },
  });

  return Response.json({ key, size: file.size, type: file.type });
}

// -------- DOWNLOAD --------

async function serveAsset(key: string, env: Env): Promise<Response> {
  const object = await env.ASSETS.get(key);

  if (!object) {
    return new Response('Not Found', { status: 404 });
  }

  // Leggi i metadata HTTP
  const headers = new Headers();
  object.writeHttpMetadata(headers);
  headers.set('etag', object.httpEtag);

  // Aggiungi headers di caching appropriati
  headers.set('Cache-Control', 'public, max-age=31536000, immutable');

  return new Response(object.body, { headers });
}

// -------- LISTING --------

async function listUploads(prefix: string, env: Env) {
  const listed = await env.ASSETS.list({
    prefix: `uploads/${prefix}`,
    limit: 100,
    // cursor: paginationCursor  // per paginare
  });

  return listed.objects.map(obj => ({
    key: obj.key,
    size: obj.size,
    uploaded: obj.uploaded.toISOString(),
    etag: obj.etag,
    customMetadata: obj.customMetadata,
  }));
}

// -------- DELETE --------

await env.ASSETS.delete('uploads/old-file.txt');

// Delete multiplo (fino a 1000 chiavi)
await env.ASSETS.delete(['file1.txt', 'file2.txt', 'file3.txt']);

// -------- HEAD: verifica esistenza senza download --------

const headResult = await env.ASSETS.head('uploads/documento.pdf');
if (headResult) {
  console.log('Size:', headResult.size);
  console.log('Uploaded:', headResult.uploaded);
}

R2 met openbare toegang en vooraf ondertekende URL

// Pattern: servire file pubblicamente tramite Worker con access control

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    // URL pattern: /files/{key}
    const key = url.pathname.replace('/files/', '');

    if (!key) {
      return new Response('Key required', { status: 400 });
    }

    // Implementa qui la tua logica di autenticazione/autorizzazione
    const authorized = await checkAccess(request, key, env);
    if (!authorized) {
      return new Response('Forbidden', { status: 403 });
    }

    // Gestisci il conditional request (ETag/If-None-Match)
    const etag = request.headers.get('If-None-Match');
    const object = await env.ASSETS.get(key, {
      onlyIf: etag ? { etagDoesNotMatch: etag } : undefined,
      range: request.headers.get('Range') ?? undefined,
    });

    if (!object) {
      // Potrebbe essere 404 o 304 Not Modified
      const head = await env.ASSETS.head(key);
      if (!head) return new Response('Not Found', { status: 404 });
      return new Response(null, { status: 304, headers: { etag: head.httpEtag } });
    }

    const headers = new Headers();
    object.writeHttpMetadata(headers);
    headers.set('etag', object.httpEtag);

    if (object.range) {
      headers.set('Content-Range', `bytes ${object.range.offset}-${
        (object.range.offset ?? 0) + (object.range.length ?? 0) - 1
      }/${object.size}`);
      return new Response(object.body, { status: 206, headers });
    }

    return new Response(object.body, { headers });
  },
};

D1 SQLite: relationele database aan de rand

D1 is het nieuwste en meest ambitieuze product: native SQLite at the edge. Een complete relationele database, met JOIN's, transacties, complexe queries, alles toegankelijk voor werknemers zonder koudestartverbinding, zonder uitstapkosten, met automatische replicatie naar alle PoP's.

D1 gebruikt SQLite intern en repliceert het naar regionale datacenters van Cloudflare. De schrijfbewerkingen gaan naar het primaire knooppunt (mogelijke consistentie voor de replica's), de leesbewerkingen ze kunnen dienen als lokale replica voor minimale latentie.

D1-configuratie in wrangler.toml

# wrangler.toml

[[d1_databases]]
binding = "DB"
database_name = "mio-database"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

# Per creare il database:
# wrangler d1 create mio-database

Migraties en schema met D1

# Crea il file di migration
wrangler d1 migrations create mio-database "create users table"
# Crea: migrations/0001_create_users_table.sql

# migrations/0001_create_users_table.sql
-- migrations/0001_create_users_table.sql
CREATE TABLE IF NOT EXISTS users (
  id        INTEGER PRIMARY KEY AUTOINCREMENT,
  email     TEXT UNIQUE NOT NULL,
  name      TEXT NOT NULL,
  role      TEXT NOT NULL DEFAULT 'user',
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);

CREATE TABLE IF NOT EXISTS sessions (
  id         TEXT PRIMARY KEY,
  user_id    INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  expires_at DATETIME NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
# Applica le migration in locale (wrangler dev)
wrangler d1 migrations apply mio-database --local

# Applica le migration in produzione
wrangler d1 migrations apply mio-database

D1 API in TypeScript: directe zoekopdrachten

// src/services/user.service.ts

export interface Env {
  DB: D1Database;
}

interface User {
  id: number;
  email: string;
  name: string;
  role: string;
  created_at: string;
}

// -------- SELECT --------

// Query prepared statement (SEMPRE usare prepared statements: previene SQL injection)
async function getUserById(id: number, env: Env): Promise<User | null> {
  const result = await env.DB
    .prepare('SELECT * FROM users WHERE id = ?')
    .bind(id)
    .first<User>();
  return result;
}

// SELECT multipli
async function getUsersByRole(role: string, env: Env): Promise<User[]> {
  const { results } = await env.DB
    .prepare('SELECT id, email, name, role, created_at FROM users WHERE role = ? ORDER BY created_at DESC')
    .bind(role)
    .all<User>();
  return results;
}

// SELECT con pagination
async function getUsers(page: number, pageSize: number, env: Env) {
  const offset = (page - 1) * pageSize;

  const [{ results }, { total }] = await Promise.all([
    env.DB
      .prepare('SELECT * FROM users LIMIT ? OFFSET ?')
      .bind(pageSize, offset)
      .all<User>(),
    env.DB
      .prepare('SELECT COUNT(*) as total FROM users')
      .first<{ total: number }>()
      .then(r => r ?? { total: 0 }),
  ]);

  return {
    users: results,
    pagination: { page, pageSize, total: total.total, totalPages: Math.ceil(total.total / pageSize) },
  };
}

// -------- INSERT --------

async function createUser(
  email: string,
  name: string,
  env: Env,
): Promise<User> {
  const result = await env.DB
    .prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
    .bind(email, name)
    .first<User>();

  if (!result) {
    throw new Error('Failed to create user');
  }

  return result;
}

// -------- UPDATE --------

async function updateUser(id: number, updates: Partial<Pick<User, 'name' | 'role'>>, env: Env) {
  const setClauses: string[] = [];
  const values: (string | number)[] = [];

  if (updates.name !== undefined) {
    setClauses.push('name = ?');
    values.push(updates.name);
  }
  if (updates.role !== undefined) {
    setClauses.push('role = ?');
    values.push(updates.role);
  }

  if (setClauses.length === 0) return;

  setClauses.push('updated_at = CURRENT_TIMESTAMP');
  values.push(id);

  await env.DB
    .prepare(`UPDATE users SET ${setClauses.join(', ')} WHERE id = ?`)
    .bind(...values)
    .run();
}

// -------- TRANSAZIONI (batch) --------

async function createUserWithSession(
  email: string,
  name: string,
  sessionId: string,
  expiresAt: Date,
  env: Env,
): Promise<User> {
  // D1 supporta batch per eseguire piu statement in una singola round-trip
  const [userResult] = await env.DB.batch([
    env.DB
      .prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
      .bind(email, name),
    env.DB
      .prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, last_insert_rowid(), ?)')
      .bind(sessionId, expiresAt.toISOString()),
  ]);

  const user = userResult.results[0] as User;
  return user;
}

D1 met motregen ORM: Type Safety Complete

Drizzle ORM ondersteunt D1 native en biedt een uitstekende ontwikkelaarservaring met automatische type-afleiding uit het schema:

// npm install drizzle-orm
// npm install -D drizzle-kit @types/better-sqlite3

// src/db/schema.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';

export const users = sqliteTable('users', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  email: text('email').unique().notNull(),
  name: text('name').notNull(),
  role: text('role', { enum: ['user', 'admin', 'moderator'] }).default('user').notNull(),
  createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
});

export const sessions = sqliteTable('sessions', {
  id: text('id').primaryKey(),
  userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  expiresAt: text('expires_at').notNull(),
  createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`),
});

// Tipi TypeScript inferiti dallo schema
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
// src/db/index.ts
import { drizzle } from 'drizzle-orm/d1';
import * as schema from './schema';

export function createDb(d1: D1Database) {
  return drizzle(d1, { schema });
}

// src/services/user.drizzle.service.ts
import { eq, desc, count } from 'drizzle-orm';
import { createDb } from '../db';
import { users, type User, type NewUser } from '../db/schema';

export async function getUserById(id: number, env: { DB: D1Database }): Promise<User | undefined> {
  const db = createDb(env.DB);
  return db.select().from(users).where(eq(users.id, id)).get();
}

export async function createUser(data: NewUser, env: { DB: D1Database }): Promise<User> {
  const db = createDb(env.DB);
  const [user] = await db.insert(users).values(data).returning();
  return user;
}

export async function getAdminUsers(env: { DB: D1Database }): Promise<User[]> {
  const db = createDb(env.DB);
  return db.select()
    .from(users)
    .where(eq(users.role, 'admin'))
    .orderBy(desc(users.createdAt))
    .all();
}

Vergelijking: KV versus R2 versus D1

Criterium Arbeiders KV R2 D1 SQLite
Gegevenstype Sleutelwaarde (tekenreeks/blob) Binaire objecten (bestanden) Relationeel (SQL-tabellen)
Samenhang Uiteindelijk consistent Eventueel (sterk met werknemers) Sterk (primair), uiteindelijk (replica)
Querymogelijkheden Alleen ophalen/plaatsen/verwijderen met de sleutel Alleen ophalen/plaatsen/verwijderen met de sleutel Volledige SQL: JOIN's, aggregaten, indexen
Maximale grootte 25 MB per waarde 5TB per stuk 10 GB (bèta: 2 GB)
Opslagkosten $ 0,50/GB/maand $ 0,015/GB/maand $ 0,75/GB/maand
Kosten van operaties $5/M schrijven, $0,50/M lezen $4,50/M schrijven, $0,36/M lezen $0,001/M rij schrijven, $0,001/M rij lezen
Lees latentie < 1 ms (uit PoP-cache) ~10-50 ms (vanaf opslag) ~1-5 ms (eenvoudige zoekopdrachten)
Ideale gebruiksscenario's Sessies, configuratie, API-cache Upload bestanden, middelen, back-ups CRUD-app, catalogus, gebruikers

Geavanceerd patroon: KV als cachelaag voor D1

Een effectief patroon combineert D1 (gezaghebbende gegevens) met KV (snelle cache):

// src/services/cached-user.service.ts

export interface Env {
  DB: D1Database;
  CACHE: KVNamespace;
}

const USER_CACHE_TTL = 300; // 5 minuti

export async function getUserCached(userId: number, env: Env) {
  const cacheKey = `user:v2:${userId}`;

  // 1. Controlla KV cache prima (sub-ms)
  const cached = await env.CACHE.get<User>(cacheKey, 'json');
  if (cached) return cached;

  // 2. Miss: leggi da D1 (query SQL)
  const user = await getUserById(userId, env);
  if (!user) return null;

  // 3. Popola la cache KV in background (non blocca la risposta)
  // (usa ctx.waitUntil() nel fetch handler per non bloccare)
  await env.CACHE.put(cacheKey, JSON.stringify(user), {
    expirationTtl: USER_CACHE_TTL,
  });

  return user;
}

export async function invalidateUserCache(userId: number, env: Env): Promise<void> {
  await env.CACHE.delete(`user:v2:${userId}`);
}

// Quando aggiorni un utente, invalida la cache
export async function updateUserAndInvalidate(
  userId: number,
  updates: Partial<User>,
  env: Env,
): Promise<void> {
  await updateUser(userId, updates, env);
  await invalidateUserCache(userId, env);
}

Conclusies en volgende stappen

De keuze van de opslaglaag is afhankelijk van het gegevenstype: KV voor sleutelwaarden met hoge leessnelheid en lage schrijfsnelheid, R2 voor binaire bestanden en assets, D1 voor gestructureerde gegevens met complexe relaties en query's. In veel toepassingen In werkelijkheid gebruik je ze alle drie in combinatie.

Volgende artikelen in de serie

  • Artikel 4: Duurzame objecten – Sterk consistente staat en WebSocket: wanneer KV niet genoeg is en u consistentie nodig heeft sterke en gedistribueerde coördinatie.
  • Artikel 5: Workers AI - Inferentie van LLM- en visiemodellen Rechtstreeks naar de rand: AI-modellen uitvoeren in Workers met behulp van AI-binding.
  • Artikel 10: Full-stack architecturen aan de edge: één voorbeeld Volledige studie waarbij Workers + D1 + R2 + CI/CD wordt gecombineerd met GitHub Actions.