跳到內容

ch15-do-storage

取得並執行

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

npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch15-do-storage
cd ch15-do-storage
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/15-do-storage.md.

Terminal window
npm install && npm run dev
Terminal window
B=localhost:8787
curl -s "$B/seed"
curl -s "$B/cursor"
curl -s "$B/one"
curl -s "$B/kv"
curl -s "$B/tx"
curl -s "$B/rawtx"
curl -s "$B/pitr"
curl -s "$B/deleteall"

2026-08-01 · wrangler@4.114.0 · workerd@1.20260722.1

{"tablesBefore":["items","__miniflare_do_name"],
"queryAfterDeleteAll":{"threw":"Error: no such table: items: SQLITE_ERROR"},
"tablesAfter":[]}

“Removes the entire contents” includes the schema. Since the constructor has already run, nothing recreates the tables — extract schema creation into a method you can call again, or ctx.abort() after wiping.

rowsRead only settles once the cursor is consumed

Section titled “rowsRead only settles once the cursor is consumed”
{"cursorProto":["next","toArray","one","raw","columnNames","rowsRead","rowsWritten","constructor"],
"columnNames":["id","name","qty"],
"rowsReadBeforeIterating":1,
"rowsReadAfterToArray":50,
"rowCount":50,
"databaseSize":16384}

That number is the billing counter, so read it after .toArray() / .one(). Combined with the documented warning that “a cursor resumed after an await may observe rows inserted, updated, or deleted after the cursor was created”, the rule is: consume the cursor before any await.

{"exactlyOne":{"ok":{"id":1,"name":"i0","qty":0}},
"zeroRows":{"threw":"Error: Expected exactly one result from SQL query, but got no results."},
"manyRows":{"threw":"Error: Expected exactly one result from SQL query, but got multiple results."},
"raw":[[1,"i0"],[2,"i1"]],
"rawHasToArray":"function"}

Not “take the first row”. Also note raw() does have toArray() at runtime even though the generated type says IterableIterator<U> — here the types are narrower than reality, the opposite of the usual direction.

{"kvProto":["get","list","put","delete","constructor"],
"getA":{"n":1},"getAIsSync":true,"missing":null,
"deleteExisting":true,"deleteMissing":false,
"listed":[["a",{"n":1}],["b","plain string"],["c",42]],
"tables":["items","__miniflare_do_name","_cf_KV"]}

No promises anywhere. _cf_KV is the hidden table backing it — visible in sqlite_master, not queryable through the SQL API. __miniflare_do_name is a local-dev artifact.

{"before":50,"after":50,
"rolledBack":{"threw":"Error: UNIQUE constraint failed: items.name: SQLITE_CONSTRAINT ..."},
"orphanSurvived":false,
"committed":{"ok":100}}

A unique violation rolled the whole transaction back, and a read-then-decide- then-write committed as one unit. Chapter 09 showed D1 rejecting this outright.

Raw BEGIN/SAVEPOINT are still prohibited:

{"begin":{"threw":"Error: To execute a transaction, please use the state.storage.transaction() or state.storage.transactionSync() APIs instead of the SQL BEGIN TRANSACTION or SAVEPOINT statements..."},
"asyncTransaction":{"ok":"ok"}}

Note this is the same error text D1 produces — except here the API it points at actually exists.

{"current":{"ok":"00000000-00000000-00000000-00000000000000000000000000000000"},
"forTimeNow":{"threw":"Error: This Durable Object's storage back-end does not implement point-in-time recovery."},
"storageProto":["get","list","put","delete","deleteAll","transaction","getAlarm","setAlarm",
"deleteAlarm","sync","transactionSync","getCurrentBookmark",
"getBookmarkForTime","onNextSessionRestoreBookmark","constructor"]}

getCurrentBookmark() returns all zeros locally and getBookmarkForTime() throws. Retention in production is 30 days.

原始碼

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "ch15-do-storage",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-24",
  "observability": { "enabled": true },
  "durable_objects": { "bindings": [{ "name": "STORE", "class_name": "Store" }] },
  "exports": { "Store": { "type": "durable-object", "storage": "sqlite" } }
}

package.json

{ "name": "ch15-do-storage", "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

import { DurableObject } from "cloudflare:workers";

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

export class Store extends DurableObject<CloudflareBindings> {
  constructor(ctx: DurableObjectState, env: CloudflareBindings) {
    super(ctx, env);
    ctx.blockConcurrencyWhile(async () => {
      ctx.storage.sql.exec(
        `CREATE TABLE IF NOT EXISTS items (
           id    INTEGER PRIMARY KEY,
           name  TEXT NOT NULL UNIQUE,
           qty   INTEGER NOT NULL DEFAULT 0
         ) STRICT`,
      );
    });
  }

  async reset(): Promise<void> {
    this.ctx.storage.sql.exec("DELETE FROM items");
  }

  async seed(n: number): Promise<number> {
    for (let i = 0; i < n; i++) {
      this.ctx.storage.sql.exec("INSERT OR IGNORE INTO items (name, qty) VALUES (?, ?)", `i${i}`, i);
    }
    return n;
  }

  /** The cursor API surface, and when rowsRead settles. */
  async cursorShape(): Promise<unknown> {
    const c = this.ctx.storage.sql.exec("SELECT * FROM items ORDER BY id");
    const proto = Object.getPrototypeOf(c);
    const rowsReadBeforeIterating = c.rowsRead;
    const arr = c.toArray();
    return {
      cursorProto: Object.getOwnPropertyNames(proto),
      columnNames: c.columnNames,
      rowsReadBeforeIterating,
      rowsReadAfterToArray: c.rowsRead,
      rowsWritten: c.rowsWritten,
      rowCount: arr.length,
      first: arr[0] ?? null,
      databaseSize: this.ctx.storage.sql.databaseSize,
    };
  }

  /** one() throws unless exactly one row. */
  async oneSemantics(): Promise<unknown> {
    return {
      exactlyOne: await t(() =>
        this.ctx.storage.sql.exec("SELECT * FROM items WHERE name = 'i0'").one(),
      ),
      zeroRows: await t(() =>
        this.ctx.storage.sql.exec("SELECT * FROM items WHERE name = 'nope'").one(),
      ),
      manyRows: await t(() => this.ctx.storage.sql.exec("SELECT * FROM items").one()),
      // Docs describe raw() as returning a RawIterator with toArray().
      // The shipped types say IterableIterator<SqlStorageValue[]> — spread it.
      raw: [...this.ctx.storage.sql.exec("SELECT id, name FROM items LIMIT 2").raw()],
      rawHasToArray:
        typeof (this.ctx.storage.sql.exec("SELECT id FROM items LIMIT 1").raw() as
          { toArray?: unknown }).toArray,
    };
  }

  /** The synchronous KV API — no promises anywhere. */
  async syncKv(): Promise<unknown> {
    const kv = this.ctx.storage.kv;
    kv.put("a", { n: 1 });
    kv.put("b", "plain string");
    kv.put("c", 42);
    const listed = [...kv.list({ prefix: "" })];
    return {
      kvProto: Object.getOwnPropertyNames(Object.getPrototypeOf(kv)),
      getA: kv.get("a"),
      getAIsSync: !(kv.get("a") instanceof Promise),
      missing: kv.get("does-not-exist") ?? null,
      deleteExisting: kv.delete("c"),
      deleteMissing: kv.delete("c"),
      listed,
      // The hidden table the KV API lives in.
      tables: this.ctx.storage.sql
        .exec("SELECT name FROM sqlite_master WHERE type='table'")
        .toArray()
        .map((r) => r.name),
    };
  }

  /** Real transactions — the thing D1 cannot do (chapter 09). */
  async transactionSyncDemo(): Promise<unknown> {
    const count = () =>
      this.ctx.storage.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM items").one().n;

    const before = count();
    const rolledBack = await t(() =>
      this.ctx.storage.transactionSync(() => {
        this.ctx.storage.sql.exec("INSERT INTO items (name, qty) VALUES ('tx-a', 1)");
        // Violates UNIQUE -> the whole transaction must roll back.
        this.ctx.storage.sql.exec("INSERT INTO items (name, qty) VALUES ('i0', 1)");
      }),
    );
    const after = count();
    const orphan = this.ctx.storage.sql
      .exec("SELECT 1 FROM items WHERE name = 'tx-a'")
      .toArray().length;

    // A read-then-write decision inside one transaction — impossible on D1.
    const committed = await t(() =>
      this.ctx.storage.transactionSync(() => {
        const q = this.ctx.storage.sql
          .exec<{ qty: number }>("SELECT qty FROM items WHERE name = 'i0'")
          .one().qty;
        this.ctx.storage.sql.exec("UPDATE items SET qty = ? WHERE name = 'i0'", q + 100);
        return q + 100;
      }),
    );

    return { before, after, rolledBack, orphanSurvived: orphan > 0, committed };
  }

  /** Raw BEGIN / SAVEPOINT are prohibited inside sql.exec(). */
  async rawTransactionAttempt(): Promise<unknown> {
    return {
      begin: await t(() => this.ctx.storage.sql.exec("BEGIN TRANSACTION")),
      savepoint: await t(() => this.ctx.storage.sql.exec("SAVEPOINT sp1")),
      asyncTransaction: await t(() =>
        this.ctx.storage.transaction(async (txn) => {
          this.ctx.storage.sql.exec("INSERT INTO items (name, qty) VALUES ('async-tx', 1)");
          return "ok";
        }),
      ),
    };
  }

  /** Point-in-time recovery bookmarks. */
  async pitr(): Promise<unknown> {
    return {
      current: await t(() => this.ctx.storage.getCurrentBookmark()),
      forTimeNow: await t(() => this.ctx.storage.getBookmarkForTime(Date.now())),
      forTime10DaysAgo: await t(() =>
        this.ctx.storage.getBookmarkForTime(Date.now() - 10 * 86400_000),
      ),
      forTime100DaysAgo: await t(() =>
        this.ctx.storage.getBookmarkForTime(Date.now() - 100 * 86400_000),
      ),
      storageProto: Object.getOwnPropertyNames(Object.getPrototypeOf(this.ctx.storage)),
    };
  }

  /** deleteAll wipes the SQL tables too, not just the KV namespace. */
  async deleteAllDemo(): Promise<unknown> {
    const before = this.ctx.storage.sql
      .exec("SELECT name FROM sqlite_master WHERE type='table'")
      .toArray().map((r) => r.name);
    const rowsBefore = this.ctx.storage.sql
      .exec<{ n: number }>("SELECT COUNT(*) AS n FROM items").one().n;
    await this.ctx.storage.deleteAll();
    const after = await t(() =>
      this.ctx.storage.sql.exec("SELECT COUNT(*) AS n FROM items").one(),
    );
    const tablesAfter = this.ctx.storage.sql
      .exec("SELECT name FROM sqlite_master WHERE type='table'")
      .toArray().map((r) => r.name);
    return { tablesBefore: before, rowsBefore, queryAfterDeleteAll: after, tablesAfter };
  }
}

export default {
  async fetch(request: Request, env: CloudflareBindings): Promise<Response> {
    const url = new URL(request.url);
    const s = env.STORE.getByName("demo");
    switch (url.pathname) {
      case "/seed":   await s.reset(); return Response.json({ seeded: await s.seed(50) });
      case "/cursor": return Response.json(await s.cursorShape());
      case "/one":    return Response.json(await s.oneSemantics());
      case "/kv":     return Response.json(await s.syncKv());
      case "/tx":     return Response.json(await s.transactionSyncDemo());
      case "/rawtx":  return Response.json(await s.rawTransactionAttempt());
      case "/pitr":   return Response.json(await s.pitr());
      case "/deleteall": return Response.json(await env.STORE.getByName("wipe-me").deleteAllDemo());
      default: return new Response("try /seed /cursor /one /kv /tx /rawtx /pitr /deleteall\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"] }