Give Your Bun Scripts a Hosted Database

Lesson 0008 · Glossary · 1 · 2 · 3 · 4 · 5 · 6 · 7 · Next: D1 scheduled writes (cron)

The win, in one line: by the end of this lesson you’ll have a typed Bun script that saves and lists notes in a real D1 database over HTTPS — Lesson 0006’s storage, no longer stuck on one laptop.

Lesson 0006 made a script remember things — but in a file on your disk. Lesson 0007 showed D1’s Worker-side API. This lesson plugs the two together: your daily-chore script calls an endpoint, the Worker calls D1, and your data lives somewhere any machine can reach. The script stays pure Bun, so it still runs on macOS, Linux, and Windows unchanged.

1. Why a Worker stands between you and D1

D1 has no public SQL-over-HTTP endpoint. The only way in is through a Worker binding — so the Worker is your API layer, and your Bun script talks HTTPS to it. That’s a feature, not a limitation: you decide (in the Worker) exactly what a caller may do, and you never expose your database.

your Bun script ── HTTPS ──▶ Worker (endpoints you wrote) ──▶ D1
  notes.ts                    /notes GET · POST ·…            hosted SQLite

(Cloudflare docs: D1 Worker API)

2. The endpoints your Worker exposes

Reuse the Worker pattern from Lesson 0007: prepare().bind().run() to write, .first() and .all() to read. New here: await request.json() parses the body your script sends — always with a content-type: application/json header.

interface Env {
  DB: D1Database;
  NOTE_KEY?: string;        // secret, set below
}

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

    if (url.pathname !== "/notes") {
      return Response.json({ error: "not found" }, { status: 404 });
    }

    if (env.NOTE_KEY && request.headers.get("x-api-key") !== env.NOTE_KEY) {
      return Response.json({ error: "forbidden" }, { status: 403 });
    }

    if (request.method === "POST") {
      const payload = (await request.json()) as { title?: string; body?: string };
      const id = crypto.randomUUID();
      const created_at = new Date().toISOString();

      await env.DB
        .prepare("INSERT OR REPLACE INTO notes (id, title, body, created_at) VALUES (?, ?, ?, ?)")
        .bind(id, payload.title?.trim() || "untitled", payload.body ?? "", created_at)
        .run();

      const note = await env.DB.prepare("SELECT * FROM notes WHERE id = ?").bind(id).first();
      return Response.json({ note });
    }

    const { results } = await env.DB.prepare("SELECT * FROM notes ORDER BY created_at").all();
    return Response.json({ notes: results });
  },
};

Two routes, that’s the whole API. The one-database-many-scripts story starts here: any script — an hourly snapshot, a phone, a second laptop — can speak this contract.

3. The Bun client: typed fetch, Lesson 5 style

Now the script you keep. Lesson 0005’s three habits carry over: check res.ok, parse with res.json(), and type the response at the boundary. Your own API gets the same treatment:

const API = process.env.API_URL ?? "http://localhost:8787";

type Note = { id: string; title: string; body: string; created_at: string };
type NoteResponse = { note: Note };
type NotesResponse = { notes: Note[] };

async function addNote(title: string, body: string): Promise<Note> {
  const res = await fetch(`${API}/notes`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ title, body }),
  });
  if (!res.ok) throw new Error(`POST /notes → ${res.status}`);
  const data = (await res.json()) as NoteResponse;
  return data.note;
}

async function listNotes(): Promise<Note[]> {
  const res = await fetch(`${API}/notes`);
  if (!res.ok) throw new Error(`GET /notes → ${res.status}`);
  const data = (await res.json()) as NotesResponse;
  return data.notes;
}
bun run notes.ts
   # added: from a bun script | id: 1409f932-…
   # total notes: 2
   #  - from a bun script
   #  - from a bun script

Notice what Lesson 0006 taught still applies: you never build values into a SQL string — the Worker does the prepare().bind() for you, and your script just ships JSON.

4. Lock it so strangers can’t write

An open POST endpoint is a public hole. Close it with one header:

(Cloudflare docs: Worker secrets)

The bash → Bun map (this layer)

bash (yesterday’s chores)Bun + Worker + D1
curl -X POST url -d '{"title":"x"}'typed addNote() that parses and validates
grep a downloaded log for last runGET /notes — the DB remembers, not the file
data trapped on one machineone hosted table, any device can reach it
secret in the script text.env → x-api-key header → Worker secret

Practice

Recall check (from memory, no peeking)

1. How does a Bun script reach a D1 database?

D1 has no public SQL-over-HTTP; your Worker is the API layer. The script does what it always does — fetch a URL.

2. How does your script prove it may write?

A header your Worker compares against its secret (env.NOTE_KEY). The value lives in .env on your side and wrangler secret put on theirs.

3. What does res.ok === false mean after a fetch?

The promise resolved — it always does for HTTP errors. Your throw new Error(...) guard (Lesson 5) turns a silent 403 page into a loud, typed failure.

Hands-on: a notes API your scripts write to

  1. Reuse your project from Lesson 0007 (or the teacher’s d1-demo): Worker skeleton, notes table schema applied with wrangler d1 execute notes-db --local --file=schema.sql.
  2. Paste the Section 2 Worker into src/index.ts; run wrangler dev.
  3. Paste Section 3 into notes.ts and run it twice — the second run’s total notes: 2 proves storage survived across processes, and it’s now over HTTPS, not a file handle.
  4. Lock it: add NOTE_KEY=dev-secret to .dev.vars, restart wrangler dev, and put NOTE_KEY=dev-secret in your script’s .env; check the script’s x-api-key header. Roam: run the script without the key and watch it fail loudly on 403.
  5. Go live: wrangler deploy, then wrangler secret put NOTE_KEY. Point notes.ts at the deployed URL via your .env (API_URL=https://d1-notes-api.…workers.dev) and run it on a different machine tomorrow — same data.

Check your work (immediate feedback):

bun run notes.ts                         # added: … | id: … · total notes: N
wrangler d1 execute notes-db --remote --command="SELECT count(*) FROM notes;"   # the CLI agrees with the script

If the count matches the script’s output, your chore scripts now have storage that outlives any laptop — and the whole chain used only lessons you already know (fetch + types + D1).

Read this next

Primary source for this lesson: reuse Cloudflare’s D1 Worker API reference, paired with Worker secrets for the auth half. When your notes grow, add LIMIT/OFFSET to the SELECT — or watch meta.rows_read in the D1 console, since D1 bills per read and write.

Next up in the D1 track: scheduled writes from cron — a Cloudflare Cron Trigger that snapshots something into this very table on a schedule, so no laptop needs to be awake.