跳到內容

ch11-d1-replication

取得並執行

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

npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch11-d1-replication
cd ch11-d1-replication
npm install

可用指令

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

說明

Companion example for docs/11-d1-replication.md.

Replication itself cannot be exercised locally (one database, no replicas), but the Sessions API is fully present, so the code you ship can be written and type-checked here.

Terminal window
npm install
npx wrangler d1 migrations apply linkforge-demo
npm run dev
Terminal window
B=localhost:8787
curl -s "$B/session"
curl -s "$B/constraints"
curl -sD- "$B/write"
curl -s "$B/read"
curl -s -H "x-d1-bookmark: <bookmark from /write>" "$B/read"

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

{"sessionProto":["constructor","_updateBookmark","prepare","batch","getBookmark",...],
"bookmarkBeforeAnyQuery":null,
"bookmarkAfterQuery":"00000000-00000003-00000000-00000000000000000000000000000000"}

getBookmark() is null before the first query — handle it, or clients get the string "null".

An invalid constraint is silently treated as a bookmark

Section titled “An invalid constraint is silently treated as a bookmark”
{"first-unconstrained": {"ok":"00000000-00000004-00000000-0000...0000"},
"first-primary": {"ok":"00000000-00000005-00000000-0000...0000"},
"not-a-real-constraint":{"ok":"not-a-real-constraint"},
"(empty string)": {"ok":"00000000-00000007-00000000-0000...0000"},
"<bookmark-shaped string>":{"ok":"00000001-00000002-00004ce6-1234567890abcdef"}}

withSession() takes constraints and bookmarks in the same string slot, so "first-unconstrainted" raises nothing — it just becomes a bookmark and the constraint you wanted never applies. TypeScript catches string literals; it cannot catch values from headers, config or env.

write -> x-d1-bookmark: 00000000-00000009-...
write -> x-d1-bookmark: 00000000-0000000c-...

Writes must go through the session too, or the bookmark never advances past them and later reads have nothing to catch up to.

localproduction
withSession() / getBookmark()yesyes
monotonic bookmarksyesyes
constraint-string behaviouryesyes
actual replica routingnoyes
served_by_region / served_by_primaryabsentpresent
reproducing a stale readnoyes

Local meta only carries served_by: "miniflare.db". Verification is the ratio of served_by_primary === true on non-write requests after deploying.

原始碼

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "ch11-d1-replication",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-24",
  "observability": { "enabled": true },
  "d1_databases": [{
    "binding": "DB",
    "database_name": "linkforge-demo",
    "database_id": "00000000-0000-0000-0000-0000000d1011",
    "migrations_dir": "./migrations"
  }]
}

package.json

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

src/index.ts

// ---------------------------------------------------------------------------
// Chapter 11 — D1 Sessions API.
//
// Replication itself cannot be exercised locally (there is one database and
// no replicas), but the SESSION API is fully present, so the code path you
// ship can be written and type-checked here.
// ---------------------------------------------------------------------------
const BOOKMARK_HEADER = "x-d1-bookmark";

const t = async (fn: () => Promise<unknown>): Promise<unknown> => {
  try { return { ok: await fn() }; }
  catch (e) { return { threw: String(e).slice(0, 200) }; }
};

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

    switch (url.pathname) {
      // What a session actually exposes, and what a bookmark looks like.
      case "/session": {
        const session = env.DB.withSession("first-unconstrained");
        const beforeAnyQuery = session.getBookmark();
        const r = await session.prepare("SELECT value FROM counters WHERE id = ?").bind("c1").all();
        const afterQuery = session.getBookmark();
        return Response.json({
          sessionProto: Object.getOwnPropertyNames(Object.getPrototypeOf(session)),
          bookmarkBeforeAnyQuery: beforeAnyQuery,
          bookmarkAfterQuery: afterQuery,
          meta: r.meta,
        });
      }

      // Which constraint values are accepted?
      case "/constraints": {
        const out: Record<string, unknown> = {};
        for (const c of ["first-unconstrained", "first-primary", "not-a-real-constraint", ""]) {
          out[c || "(empty string)"] = await t(async () => {
            const s = env.DB.withSession(c as never);
            await s.prepare("SELECT 1 AS x").first();
            return s.getBookmark();
          });
        }
        // A bookmark string is also accepted in the same slot.
        out["<bookmark-shaped string>"] = await t(async () => {
          const s = env.DB.withSession("00000001-00000002-00004ce6-1234567890abcdef");
          await s.prepare("SELECT 1 AS x").first();
          return s.getBookmark();
        });
        return Response.json(out);
      }

      // The shape you actually ship: read the bookmark in, echo it back out.
      case "/read": {
        const incoming = request.headers.get(BOOKMARK_HEADER);
        const session = env.DB.withSession(incoming ?? "first-unconstrained");
        const row = await session
          .prepare("SELECT value FROM counters WHERE id = ? LIMIT 1")
          .bind("c1")
          .first<{ value: number }>();
        const res = Response.json({ value: row?.value ?? null, usedBookmark: incoming });
        const b = session.getBookmark();
        if (b) res.headers.set(BOOKMARK_HEADER, b);
        return res;
      }

      // Writes must go through the session too, or the bookmark will not
      // advance and a later read can legally serve a stale replica.
      case "/write": {
        const session = env.DB.withSession("first-primary");
        await session
          .prepare("UPDATE counters SET value = value + 1 WHERE id = ?")
          .bind("c1")
          .run();
        const res = Response.json({ ok: true });
        const b = session.getBookmark();
        if (b) res.headers.set(BOOKMARK_HEADER, b);
        return res;
      }

      // Same query without a session, for comparison.
      case "/plain": {
        const r = await env.DB.prepare("SELECT value FROM counters WHERE id = ? LIMIT 1")
          .bind("c1").all();
        return Response.json({ meta: r.meta });
      }

      default:
        return new Response("try /session /constraints /read /write /plain\n", { status: 404 });
    }
  },
} satisfies ExportedHandler<CloudflareBindings>;

migrations/0001_init.sql

CREATE TABLE IF NOT EXISTS counters (
  id     TEXT    PRIMARY KEY,
  value  INTEGER NOT NULL DEFAULT 0
) STRICT;
INSERT OR IGNORE INTO counters (id, value) VALUES ('c1', 0);

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"] }