Skip to content

jangerhofer/local-d1-proxy

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

local-d1-proxy

local-d1-proxy helps Cloudflare D1 projects run multiple local processes against the same local database state.

If you have a local app server reading from D1 while a background worker writes to the same persisted Wrangler state, both processes may ask Wrangler/Miniflare for their own local D1 binding. In that shape, reads can hang, retry, or fail with errors like D1_ERROR: Failed to parse body as JSON, D1DatabaseObject.queryExecute, SQLITE_BUSY, or SQLITE_BUSY_SNAPSHOT. The same pattern also works for CLIs, test runners, or other local consumers that need that shared D1 state.

This package runs one local D1 owner process and gives participating local consumers a client for the common D1 API subset over HTTP. Application code keeps the common D1 call shape: prepare() and batch(), plus prepared statement methods like bind(), first(), raw(), and run().

The Mechanism section describes the ownership invariant and HTTP protocol in more detail.

N.b. this is not a production gateway or a SQLite tuning layer; it is merely a local coordination process to make Miniflare errors less painful in local development.

Example Shape

flowchart LR
  App["web app"] --> Proxy["local-d1-proxy"]
  Worker["background worker"] --> Proxy
  CLI["CLI/tests"] --> Proxy
  Proxy --> Wrangler["Wrangler getPlatformProxy"]
  Wrangler --> D1["local D1 binding"]
Loading

The important boundary is ownership: only the proxy opens local D1. Other participating processes receive a D1 subset client backed by HTTP.

Quick Start

bun install
bun run src/cli.ts serve \
  --project-dir /path/to/project \
  --config /path/to/project/wrangler.jsonc \
  --persist-to /path/to/project/.wrangler/state/v3 \
  --binding DB

The --config and --persist-to values should match the project state used by D1 local development and other Workers local data bindings.

Inside nix develop, the flake prepends this repository's bin/ directory, so the command is shorter:

local-d1-proxy serve \
  --project-dir /path/to/project \
  --config /path/to/project/wrangler.jsonc \
  --persist-to /path/to/project/.wrangler/state/v3 \
  --binding DB

The direct bun run src/cli.ts ... form remains supported for projects that do not use Nix.

The proxy has no authentication. Keep --host at the default 127.0.0.1 unless exposing local D1 access is intentional; any client that can reach POST /d1 can execute SQL against that local D1 binding.

Consumer

export LOCAL_D1_PROXY_URL=http://127.0.0.1:8789
import { make_d1_http_proxy_database } from "local-d1-proxy/client"

const database = make_d1_http_proxy_database({
  proxy_url: process.env.LOCAL_D1_PROXY_URL!
})

const result = await database.prepare("select ? as value").bind("example").all()

In deployed Cloudflare environments, keep using the real D1 binding.

macOS Service

Use the launchd helper when the proxy should be available before either the app server or background worker starts. That keeps both processes pointed at the same local D1 owner without requiring a manually managed terminal session.

local-d1-proxy install-service \
  --project-dir /path/to/project \
  --config /path/to/project/wrangler.jsonc \
  --persist-to /path/to/project/.wrangler/state/v3 \
  --binding DB
local-d1-proxy status-service --project-dir /path/to/project
local-d1-proxy uninstall-service --project-dir /path/to/project

The default launchd label is derived from the resolved --project-dir, including a short path hash to avoid same-basename collisions. Pass --label to pin an explicit project-specific name.

Mechanism

Local D1 is SQLite-backed, but application code does not talk to SQLite directly. It talks through Wrangler/Miniflare's D1 runtime. In a multi-process dev setup, separate processes can accidentally create separate local D1 owners against the same persisted state. Under concurrent reads and writes, that ownership split can produce slow reads, transient internal D1 failures, or noisy retry storms.

A checked-in stress run exercises this ownership split directly and records the proxy-owned path completing cleanly.

The invariant is simple: exactly one local process opens the D1 binding. Local consumers that need the shared database use a D1 subset client that sends parameterized operations to that owner over HTTP.

The owner process:

  • calls Wrangler getPlatformProxy(...)
  • resolves the configured D1 binding once
  • serves GET /health, returning { "ok": true, "result": { "ready": true } } when the configured D1 binding can be resolved
  • serves POST /d1
  • executes all, first, raw, run, and batch through the real binding

Required owner inputs:

  • Wrangler config path
  • Miniflare persist path, usually .wrangler/state/v3
  • D1 binding name
  • proxy host and port

POST /d1 accepts a single statement:

{
  "method": "all",
  "sql": "select ? as value",
  "params": ["example"]
}

or a batch:

{
  "method": "batch",
  "statements": [
    {
      "sql": "insert into events (id) values (?)",
      "params": ["evt_1"]
    }
  ]
}

SQL and parameters are transported separately. Consumers must keep using bind parameters; do not interpolate values into SQL strings.

For a dev server, worker, or CLI:

  1. Check GET /health.
  2. If unhealthy, start or kick the proxy service.
  3. Start the consumer with LOCAL_D1_PROXY_URL set.
  4. Ensure the consumer does not also create its own local D1 owner.

Development Shell

nix develop
bun install
bun test

The flake intentionally includes Bun and Git only. Wrangler is an npm dependency imported by the proxy process; projects that need the Wrangler CLI directly should install it in the consuming project.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors