跳到內容

ch08-kv

取得並執行

這個範例可以獨立 clone 執行,不依賴其他章節。

npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch08-kv
cd ch08-kv
npm install

可用指令

npm run dev	# wrangler dev
npm run deploy	# wrangler deploy
npm run typecheck	# tsc --noEmit
npm run cf-typegen	# wrangler types --env-interface CloudflareBindings

說明

Companion example for docs/08-kv.md.

Every limit in the chapter was hit here on purpose. No Cloudflare account needed — everything runs against local .wrangler/state/.

Terminal window
npm install && npm run dev
Terminal window
B=localhost:8787
curl -s "$B/seed?n=120"
curl -s "$B/bulk?n=100"
curl -s "$B/bulk?n=101"
curl -s "$B/ttl"
curl -s "$B/metadata"
curl -s "$B/list"
curl -s "$B/cachettl"
curl -s "$B/hammer"

2026-07-28 · wrangler@4.114.0 · workerd@1.20260722.1

{"requested":100,"ok":{"type":"Map","size":100,"sample":[["k:0000",{"i":0}],["k:0001",{"i":1}]]}}
{"requested":101,"threw":"Error: KV GET_BULK failed: 400 You can request a maximum of 100 keys"}

100 keys bill as one operation. At $0.50/million reads that is a 100x cost difference, and it saves 99 subrequests against the Free plan’s 50.

{"ttl=10": {"threw":"KV PUT failed: 400 Invalid expiration_ttl of 10. Expiration TTL must be at least 60."},
"ttl=59": {"threw":"KV PUT failed: 400 Invalid expiration_ttl of 59. Expiration TTL must be at least 60."},
"ttl=60": {}, "ttl=61": {}}

Metadata limit counts the serialised length

Section titled “Metadata limit counts the serialised length”
{"bytes=100": {}, "bytes=1000": {},
"bytes=1024": {"threw":"KV PUT failed: 413 Metadata length of 1034 exceeds limit of 1024."},
"bytes=2000": {"threw":"KV PUT failed: 413 Metadata length of 2010 exceeds limit of 1024."}}

1024 bytes of payload fails — the {"pad":"..."} wrapper adds 10 bytes.

cacheTtl floor is 30s (was 60 before 2026-01-30)

Section titled “cacheTtl floor is 30s (was 60 before 2026-01-30)”
{"cacheTtl=10":{"threw":"KV GET failed: 400 Invalid cache_ttl of 10. Cache TTL must be at least 30."},
"cacheTtl=29":{"threw":"... Cache TTL must be at least 30."},
"cacheTtl=30":{"ok":"{\"i\":0}"},"cacheTtl=60":{"ok":"{\"i\":0}"}}

list() — a short page is not the last page

Section titled “list() — a short page is not the last page”
{"keys":[{"name":"k:0000","metadata":{"even":true}}, ...],
"list_complete":false,"cursor":"azowMDAy","cacheStatus":null,
"naiveDoneCheck":false}

keys.length === limit === 3 yet list_complete is false. KV can also return a shorter page that still is not the end, because it scans internal shards. Always branch on list_complete.

Note metadata comes back inside list() results — putting list-view fields there removes the need for a per-key get().

The generated types model this correctly: only the list_complete: false branch carries cursor.

The 1 write/sec/key limit is NOT enforced locally

Section titled “The 1 write/sec/key limit is NOT enforced locally”
{"writes":8,"results":[{},{},{},{},{},{},{},{}],"final":"7"}

Eight consecutive writes to one key, all accepted. In production the excess writes are dropped silently. Local testing gives no signal at all — this has to be caught in review.

原始碼

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "ch08-kv",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-24",
  "observability": { "enabled": true },
  "kv_namespaces": [{ "binding": "KV", "id": "0000000000000000000000000000cccc" }]
}

package.json

{ "name": "ch08-kv", "private": true, "type": "module",
  "scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", "typecheck": "tsc --noEmit",
    "cf-typegen": "wrangler types --env-interface CloudflareBindings" },
  "devDependencies": { "typescript": "^5.9.0", "wrangler": "^4.114.0" } }

src/index.ts

// ---------------------------------------------------------------------------
// Chapter 08 — Workers KV behaviour probes.
// ---------------------------------------------------------------------------
const t = async (fn: () => Promise<unknown>): Promise<unknown> => {
  try { return { ok: await fn() }; }
  catch (e) { return { threw: String(e).slice(0, 160) }; }
};

export default {
  async fetch(request: Request, env: CloudflareBindings): Promise<Response> {
    const url = new URL(request.url);
    const kv = env.KV;

    switch (url.pathname) {
      case "/seed": {
        const n = Number(url.searchParams.get("n") ?? 5);
        for (let i = 0; i < n; i++) {
          await kv.put(`k:${String(i).padStart(4, "0")}`, JSON.stringify({ i }), {
            metadata: { even: i % 2 === 0 },
          });
        }
        return Response.json({ seeded: n });
      }

      // Bulk get: array key, returns a Map. Max 100 keys.
      case "/bulk": {
        const n = Number(url.searchParams.get("n") ?? 5);
        const keys = Array.from({ length: n }, (_, i) => `k:${String(i).padStart(4, "0")}`);
        const r = await t(async () => {
          const m = await kv.get(keys, "json");
          return { type: m?.constructor?.name, size: m?.size, sample: [...(m ?? [])].slice(0, 2) };
        });
        return Response.json({ requested: n, ...(r as object) });
      }

      // expirationTtl has a documented 60s floor.
      case "/ttl": {
        const out: Record<string, unknown> = {};
        for (const ttl of [10, 59, 60, 61]) {
          out[`ttl=${ttl}`] = await t(() => kv.put(`ttl:${ttl}`, "v", { expirationTtl: ttl }));
        }
        return Response.json(out);
      }

      // metadata is capped at 1024 bytes serialised.
      case "/metadata": {
        const out: Record<string, unknown> = {};
        for (const size of [100, 1000, 1024, 2000]) {
          out[`bytes=${size}`] = await t(() =>
            kv.put(`meta:${size}`, "v", { metadata: { pad: "x".repeat(size) } }),
          );
        }
        return Response.json(out);
      }

      case "/list": {
        const page = await kv.list<{ even: boolean }>({ prefix: "k:", limit: 3 });
        return Response.json({
          keys: page.keys,
          list_complete: page.list_complete,
          cursor: "cursor" in page ? page.cursor : null,
          cacheStatus: page.cacheStatus,
          // The trap: keys.length < limit does NOT mean you are done.
          naiveDoneCheck: page.keys.length < 3,
        });
      }

      // A missing key is null, and the lookup is still billed.
      case "/miss":
        return Response.json({ value: await kv.get("definitely-not-here") });

      // Hammering a single key: the 1 write/sec/key limit is a production
      // behaviour, not enforced locally.
      case "/hammer": {
        const results: unknown[] = [];
        for (let i = 0; i < 8; i++) results.push(await t(() => kv.put("hot", String(i))));
        return Response.json({ writes: results.length, results, final: await kv.get("hot") });
      }

      case "/cachettl": {
        const out: Record<string, unknown> = {};
        for (const c of [10, 29, 30, 60]) {
          out[`cacheTtl=${c}`] = await t(() => kv.get("k:0000", { cacheTtl: c }));
        }
        return Response.json(out);
      }

      default:
        return new Response(
          "try /seed?n=120 /bulk?n=100 /bulk?n=101 /ttl /metadata /list /miss /hammer /cachettl\n",
          { status: 404 },
        );
    }
  },
} satisfies ExportedHandler<CloudflareBindings>;

tsconfig.json

{ "compilerOptions": { "target": "esnext", "lib": ["esnext"], "module": "esnext",
  "moduleResolution": "bundler", "types": ["./worker-configuration.d.ts"],
  "strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true },
  "include": ["src/**/*.ts", "worker-configuration.d.ts"] }