跳到內容

ch10-drizzle

取得並執行

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

npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch10-drizzle
cd ch10-drizzle
npm install

可用指令

npm run dev	# wrangler dev
npm run typecheck	# tsc --noEmit
npm run cf-typegen	# wrangler types --env-interface CloudflareBindings
npm run db:apply	# wrangler d1 migrations apply linkforge-demo
npm run db:generate	# drizzle-kit generate

說明

Companion example for docs/10-drizzle.md.

Pinned to the 0.4x line — what npm i drizzle-orm actually installs today. The Drizzle docs describe the 1.0 RC line instead; the two are incompatible.

Terminal window
npm install
npx drizzle-kit generate
npx wrangler d1 migrations apply linkforge-demo # defaults to LOCAL (ch09)
npm run dev
Terminal window
B=localhost:8787
curl -s "$B/seed"
curl -s "$B/types"
curl -s "$B/batch"
curl -s "$B/batch-rollback"
curl -s "$B/transaction"
curl -s "$B/plan"

2026-07-28 · drizzle-orm@0.45.2 · drizzle-kit@0.31.10 · wrangler@4.114.0

Two incompatible lines shipping simultaneously

Section titled “Two incompatible lines shipping simultaneously”
$ npm view drizzle-orm dist-tags
{ "latest": "0.45.2", "beta": "1.0.0-beta.22", "rc": "1.0.0-rc.4", ... }

Plus ~50 other dist-tags (beelink, revert-netlify, kit-duckdb, …). Pin exact versions; do not use ^.

drizzle-kit@0.31.10:

drizzle/0000_complex_tyger_tiger.sql
drizzle/meta/0000_snapshot.json
drizzle/meta/_journal.json

drizzle-kit@1.0.0-rc.4:

drizzle/20260728080351_sparkling_cable/migration.sql
drizzle/20260728080351_sparkling_cable/snapshot.json

Flat + journal versus nested folders, and sequence numbers become timestamps.

Wrangler and the 1.0 layout — a silent green CI

Section titled “Wrangler and the 1.0 layout — a silent green CI”

Without migrations_pattern:

▲ [WARNING] Could not find any migration files matching `drizzle/*.sql`. It looks like
there are migration files matching `drizzle/*/migration.sql` though. If you are using
drizzle to manage your migrations, please set `migrations_pattern` to
`drizzle/*/migration.sql` in wrangler.jsonc.
✅ No migrations to apply!

Note the exit is 0. CI passes and production has no tables. With "migrations_pattern": "drizzle/*/migration.sql" it applies correctly and records the name as 20260728080351_sparkling_cable/migration.sql.

{"threw":"Error: Failed query: begin\nparams: "}

Drizzle emits a bare begin, which D1 rejects (chapter 09). Same in 1.0.0-rc.4. Tracking issue: drizzle-team/drizzle-orm#2463, still open.

batch() is the atomic path, with positional types

Section titled “batch() is the atomic path, with positional types”
[[{"id":"t1","name":"Acme","plan":"pro",...}],
[{"n":2}],
{"success":true,"meta":{"rows_read":3,"rows_written":4,...},"results":[]}]

Rollback verified:

{"before":3,"after":3,
"err":{"threw":"D1_ERROR: UNIQUE constraint failed: links.tenant_id, links.slug ..."},
"firstRowSurvived":false}

Keep the array inline — hoisting it to a const widens the tuple and loses per-item types.

mode fixes chapter 09’s lossy round-trips

Section titled “mode fixes chapter 09’s lossy round-trips”
{"row":{"isActive":true,"createdAt":"2026-07-28T08:05:41.656Z","expiresAt":null},
"isActiveType":"boolean","createdAtIsDate":true}

The ORM does not protect you from rows_read

Section titled “The ORM does not protect you from rows_read”

db.select().from(links).where(eq(links.slug, "cf")).orderBy(desc(links.createdAt)) against a schema whose unique index is (tenant_id, slug):

{"plan":[{"detail":"SCAN links"},{"detail":"USE TEMP B-TREE FOR ORDER BY"}]}

The composite index is unused because the query filters only on slug, not on the leftmost column. Obvious in raw SQL, invisible behind .where(eq(...)). Use .toSQL() and feed it to EXPLAIN QUERY PLAN.

Exercise: change the filter to and(eq(links.tenantId, "t1"), eq(links.slug, "cf")) and watch SCAN become SEARCH links USING INDEX idx_links_tenant_slug.

原始碼

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "ch10-drizzle",
  "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-0000000d1010",
      "migrations_dir": "./drizzle"
    }
  ]
}

package.json

{ "name": "ch10-drizzle", "private": true, "type": "module",
  "scripts": { "dev": "wrangler dev", "typecheck": "tsc --noEmit",
    "cf-typegen": "wrangler types --env-interface CloudflareBindings",
    "db:generate": "drizzle-kit generate",
    "db:apply": "wrangler d1 migrations apply linkforge-demo" },
  "dependencies": { "drizzle-orm": "^0.45.2" },
  "devDependencies": { "drizzle-kit": "^0.31.10", "typescript": "^5.9.0", "wrangler": "^4.114.0" } }

src/db.ts

import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";

/**
 * Build a Drizzle client per request. Bindings live on `env`, so there is no
 * module-level singleton to create (chapter 03).
 *
 * NOTE (0.4x line): the option is `schema`. On the 1.0 line it is `relations`
 * and `schema` is Omit-ed away for SQLite drivers.
 */
export const getDb = (env: CloudflareBindings) => drizzle(env.DB, { schema });
export type Db = ReturnType<typeof getDb>;

src/index.ts

import { eq, desc, sql } from "drizzle-orm";
import { getDb } from "./db";
import { links, tenants } from "./schema";

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

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

    switch (url.pathname) {
      case "/seed": {
        await db.delete(links);
        await db.delete(tenants);
        await db.insert(tenants).values({ id: "t1", name: "Acme", plan: "pro" });
        await db.insert(links).values([
          { tenantId: "t1", slug: "cf", url: "https://developers.cloudflare.com/", createdAt: new Date() },
          { tenantId: "t1", slug: "hono", url: "https://hono.dev/", createdAt: new Date() },
        ]);
        return Response.json({ seeded: true });
      }

      // mode:"boolean" and mode:"timestamp_ms" fix chapter 09's lossy round-trips.
      case "/types": {
        const row = await db.select().from(links).where(eq(links.slug, "cf")).get();
        return Response.json({
          row,
          isActiveType: typeof row?.isActive,
          createdAtIsDate: row?.createdAt instanceof Date,
        });
      }

      // batch() keeps per-statement result types positionally.
      case "/batch": {
        const r = await t(() =>
          db.batch([
            db.select().from(tenants),
            db.select({ n: sql<number>`count(*)` }).from(links),
            db.insert(links).values({
              tenantId: "t1", slug: `b${Date.now()}`, url: "https://x", createdAt: new Date(),
            }),
          ]),
        );
        return Response.json(r);
      }

      // Whole-batch rollback on a unique violation.
      case "/batch-rollback": {
        const before = await db.$count(links);
        const err = await t(() =>
          db.batch([
            db.insert(links).values({ tenantId: "t1", slug: "will-rollback", url: "https://a", createdAt: new Date() }),
            db.insert(links).values({ tenantId: "t1", slug: "cf", url: "https://dup", createdAt: new Date() }),
          ]),
        );
        const after = await db.$count(links);
        const survivor = await db.select().from(links).where(eq(links.slug, "will-rollback")).get();
        return Response.json({ before, after, err, firstRowSurvived: survivor !== undefined });
      }

      // THE trap: this type-checks and fails at runtime on D1.
      case "/transaction": {
        const r = await t(() =>
          db.transaction(async (tx) => {
            await tx.insert(links).values({
              tenantId: "t1", slug: `tx${Date.now()}`, url: "https://tx", createdAt: new Date(),
            });
            return "committed";
          }),
        );
        return Response.json(r);
      }

      // Cost check: does Drizzle's generated SQL still use the index?
      case "/plan": {
        const q = db.select().from(links)
          .where(eq(links.slug, "cf"))
          .orderBy(desc(links.createdAt));
        const { sql: text, params } = q.toSQL();
        const plan = await env.DB.prepare(`EXPLAIN QUERY PLAN ${text}`).bind(...params).all();
        return Response.json({ sql: text, params, plan: plan.results });
      }

      default:
        return new Response("try /seed /types /batch /batch-rollback /transaction /plan\n", { status: 404 });
    }
  },
} satisfies ExportedHandler<CloudflareBindings>;

src/schema.ts

import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";

export const tenants = sqliteTable("tenants", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  plan: text("plan", { enum: ["free", "pro"] }).notNull().default("free"),
  createdAt: integer("created_at").notNull().default(sql`(unixepoch() * 1000)`),
});

export const links = sqliteTable(
  "links",
  {
    id: integer("id").primaryKey({ autoIncrement: true }),
    tenantId: text("tenant_id").notNull().references(() => tenants.id),
    slug: text("slug").notNull(),
    url: text("url").notNull(),
    // SQLite has no BOOLEAN. mode:"boolean" makes Drizzle do the 0/1 mapping
    // that chapter 09 had to do by hand.
    isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
    createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
    expiresAt: integer("expires_at", { mode: "timestamp_ms" }),
  },
  (t) => [
    uniqueIndex("idx_links_tenant_slug").on(t.tenantId, t.slug),
    index("idx_links_tenant_created").on(t.tenantId, t.createdAt),
  ],
);

export type Link = typeof links.$inferSelect;
export type NewLink = typeof links.$inferInsert;

drizzle/0000_complex_tyger_tiger.sql

CREATE TABLE `links` (
	`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
	`tenant_id` text NOT NULL,
	`slug` text NOT NULL,
	`url` text NOT NULL,
	`is_active` integer DEFAULT true NOT NULL,
	`created_at` integer NOT NULL,
	`expires_at` integer,
	FOREIGN KEY (`tenant_id`) REFERENCES `tenants`(`id`) ON UPDATE no action ON DELETE no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `idx_links_tenant_slug` ON `links` (`tenant_id`,`slug`);--> statement-breakpoint
CREATE INDEX `idx_links_tenant_created` ON `links` (`tenant_id`,`created_at`);--> statement-breakpoint
CREATE TABLE `tenants` (
	`id` text PRIMARY KEY NOT NULL,
	`name` text NOT NULL,
	`plan` text DEFAULT 'free' NOT NULL,
	`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL
);

drizzle/meta/_journal.json

{
  "version": "7",
  "dialect": "sqlite",
  "entries": [
    {
      "idx": 0,
      "version": "6",
      "when": 1785225792554,
      "tag": "0000_complex_tyger_tiger",
      "breakpoints": true
    }
  ]
}

drizzle/meta/0000_snapshot.json

{
  "version": "6",
  "dialect": "sqlite",
  "id": "a0e50d1f-30c6-4129-acee-45d922a1b5b8",
  "prevId": "00000000-0000-0000-0000-000000000000",
  "tables": {
    "links": {
      "name": "links",
      "columns": {
        "id": {
          "name": "id",
          "type": "integer",
          "primaryKey": true,
          "notNull": true,
          "autoincrement": true
        },
        "tenant_id": {
          "name": "tenant_id",
          "type": "text",
          "primaryKey": false,
          "notNull": true,
          "autoincrement": false
        },
        "slug": {
          "name": "slug",
          "type": "text",
          "primaryKey": false,
          "notNull": true,
          "autoincrement": false
        },
        "url": {
          "name": "url",
          "type": "text",
          "primaryKey": false,
          "notNull": true,
          "autoincrement": false
        },
        "is_active": {
          "name": "is_active",
          "type": "integer",
          "primaryKey": false,
          "notNull": true,
          "autoincrement": false,
          "default": true
        },
        "created_at": {
          "name": "created_at",
          "type": "integer",
          "primaryKey": false,
          "notNull": true,
          "autoincrement": false
        },
        "expires_at": {
          "name": "expires_at",
          "type": "integer",
          "primaryKey": false,
          "notNull": false,
          "autoincrement": false
        }
      },
      "indexes": {
        "idx_links_tenant_slug": {
          "name": "idx_links_tenant_slug",
          "columns": [
            "tenant_id",
            "slug"
          ],
          "isUnique": true
        },
        "idx_links_tenant_created": {
          "name": "idx_links_tenant_created",
          "columns": [
            "tenant_id",
            "created_at"
          ],
          "isUnique": false
        }
      },
      "foreignKeys": {
        "links_tenant_id_tenants_id_fk": {
          "name": "links_tenant_id_tenants_id_fk",
          "tableFrom": "links",
          "tableTo": "tenants",
          "columnsFrom": [
            "tenant_id"
          ],
          "columnsTo": [
            "id"
          ],
          "onDelete": "no action",
          "onUpdate": "no action"
        }
      },
      "compositePrimaryKeys": {},
      "uniqueConstraints": {},
      "checkConstraints": {}
    },
    "tenants": {
      "name": "tenants",
      "columns": {
        "id": {
          "name": "id",
          "type": "text",
          "primaryKey": true,
          "notNull": true,
          "autoincrement": false
        },
        "name": {
          "name": "name",
          "type": "text",
          "primaryKey": false,
          "notNull": true,
          "autoincrement": false
        },
        "plan": {
          "name": "plan",
          "type": "text",
          "primaryKey": false,
          "notNull": true,
          "autoincrement": false,
          "default": "'free'"
        },
        "created_at": {
          "name": "created_at",
          "type": "integer",
          "primaryKey": false,
          "notNull": true,
          "autoincrement": false,
          "default": "(unixepoch() * 1000)"
        }
      },
      "indexes": {},
      "foreignKeys": {},
      "compositePrimaryKeys": {},
      "uniqueConstraints": {},
      "checkConstraints": {}
    }
  },
  "views": {},
  "enums": {},
  "_meta": {
    "schemas": {},
    "tables": {},
    "columns": {}
  },
  "internal": {
    "indexes": {}
  }
}

drizzle.config.ts

import { defineConfig } from "drizzle-kit";

export default defineConfig({
  out: "./drizzle",
  schema: "./src/schema.ts",
  dialect: "sqlite",
  // d1-http is for drizzle-kit push/pull/studio against a REMOTE D1.
  // Migrations themselves are applied with `wrangler d1 migrations apply`.
  driver: "d1-http",
  dbCredentials: {
    accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "",
    databaseId: process.env.CLOUDFLARE_DATABASE_ID ?? "",
    token: process.env.CLOUDFLARE_D1_TOKEN ?? "",
  },
});

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