ch15-do-storage
對應 15. DO SQLite Storage:每個 object 一顆資料庫
在 GitHub 上檢視·4 個檔案·8.1 KB
取得並執行
這個範例可以獨立 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說明
ch15 — Durable Object SQLite storage
Section titled “ch15 — Durable Object SQLite storage”Companion example for docs/15-do-storage.md.
npm install && npm run devB=localhost:8787curl -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"Verified findings
Section titled “Verified findings”2026-08-01 · wrangler@4.114.0 · workerd@1.20260722.1
deleteAll() drops the tables
Section titled “deleteAll() drops the tables”{"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.
one() is strict
Section titled “one() is strict”{"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.
The synchronous KV API
Section titled “The synchronous KV API”{"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.
transactionSync() does what D1 cannot
Section titled “transactionSync() does what D1 cannot”{"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.
PITR is production-only
Section titled “PITR is production-only”{"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"] }