cloudflare / cloudflare/workers-sdk

D1: server-side statement splitter matches trigger BEGIN case-sensitively, so lowercase 'begin' bodies fail d1 migrations apply --remote (refines #14991)

Open
#15,314 1 comment 0 reactions 0 assignees View on GitHub
product:d1
Dominant language
TypeScript
Stars
4.5k
Forks
1.5k
Avg merge
3d 8h
Merged PRs (30d)
187

Description

### Which Cloudflare product(s) does this pertain to?

D1, Wrangler

### What versions are you using?

wrangler 4.125.0 (Windows 11, Node 22)

### What operating system and version are you using?

Windows 11

### Describe the Bug

`wrangler d1 migrations apply --remote` fails with

```
X [ERROR] A request to the Cloudflare API (.../d1/database//query) failed.
incomplete input: SQLITE_ERROR [code: 7500]
```

for any migration containing a `CREATE TRIGGER ... BEGIN ...; END;` body written with a **lowercase `begin`**.

This is closely related to #14991, which was closed after attributing the same error to CRLF line endings. That attribution is incomplete: converting the migrations to pure LF (verified byte-wise, 0 `CR` bytes) does **not** fix it. Both reports are the same underlying bug, and the real variable is narrower and more surprising.

### Root cause: D1's server-side splitter matches `BEGIN` case-sensitively

`d1 migrations apply --remote` does not split migration files client-side. It reads the file, appends its own `INSERT INTO "d1_migrations" (name) values (...)`, and POSTs the whole thing to `/query` as a single `sql` string (`buildMigrationQuery` in `src/d1/migrations.ts`), leaving the splitting to D1's server side.

D1's server-side splitter *is* trigger-aware — it enters "inside a trigger body" mode on a `BEGIN` token and correctly ignores the inner `;`. But that token match is brittle. Bisected against real remote D1 databases (region ENAM), posting each statement **alone** to `/query`:

| `CREATE TRIGGER` body | result |
|---|---|
| `BEGIN` … `END` (uppercase, LF) | ✅ applies |
| `BEGIN` … `end` (uppercase `BEGIN`, lowercase `end`) | ✅ applies |
| `begin` … `END` (lowercase `BEGIN`) | ❌ `incomplete input` |
| `begin` … `end` (all lowercase) | ❌ `incomplete input` |
| `Begin` … `End` (mixed case) | ❌ `incomplete input` |
| `BEGIN` … `END` with **CRLF** line endings | ❌ `incomplete input` |

**The load-bearing token is `BEGIN`, matched uppercase-only, and additionally broken by the `\r` of a CRLF line ending. `END`'s casing is irrelevant** — once the splitter is in trigger mode it finds the end either way.

SQLite itself is of course case-insensitive here, so all six forms above are valid SQL and all six apply fine locally.

### Minimal reproduction

```bash
wrangler d1 create splitter-repro

# lowercase `begin` — fails
wrangler d1 execute splitter-repro --remote --command \
"create table t (id integer primary key, a text, b text);"
wrangler d1 execute splitter-repro --remote --command \
"create trigger t_sync after insert on t for each row begin update t set b = new.a where id = new.id; end;"
# X [ERROR] incomplete input: SQLITE_ERROR [code: 7500]

# byte-identical except BEGIN is uppercase — succeeds
wrangler d1 execute splitter-repro --remote --command \
"create trigger t_sync2 after insert on t for each row BEGIN update t set b = new.a where id = new.id; end;"
# 🚣 Executed 1 command
```

The same contrast reproduces directly against the REST API, so it is server-side and not a Wrangler string-handling artifact:

```
POST /accounts/{acct}/d1/database/{db}/query {"sql": "...begin ...; end;"} -> 400 incomplete input: SQLITE_ERROR
POST /accounts/{acct}/d1/database/{db}/query {"sql": "...BEGIN ...; end;"} -> 200 success
```

It also fails identically when the single trigger statement is the only element of a `batch` array, and on `/raw`.

### Why `d1 execute --remote --file` is not a counterexample

#14991 noted that the identical bytes succeed via `d1 execute --remote --file`. That is not because Wrangler splits client-side on that path — it is because `--file` does not use `/query` at all. It uploads to R2 and uses the `/import` endpoint, whose server-side parser handles trigger bodies correctly regardless of casing or line endings.

### Impact

A migration directory that applies cleanly under `--local`, `sqlite::memory:`, and `node:sqlite` cannot be applied to a real remote database. Because every local and CI path passes, this is invisible until a production deploy. In our case migration `0002` of 6 defines five lowercase triggers, so the remote schema could not be built past `0001` at all.

### Suggested fixes

Either would resolve it; the first is the actual defect:

1. **Make D1's server-side splitter match `BEGIN`/`END` case-insensitively and tolerate `\r`.** This is the real bug — the same input is valid SQL that SQLite accepts.
2. **Have `d1 migrations apply` split client-side**, the way `unstable_splitSqlQuery` already does for the local path, and send discrete statements — or route migration files through the same `/import` path `d1 execute --file` uses. Wrangler already normalises line endings via `normalizeSqlLineEndings` on the `--command` path but not on the migrations path.

### Workaround

Uppercase `BEGIN` in every trigger body and force LF (`migrations/*.sql text eol=lf`). Both are required; neither alone is sufficient. We chose not to rely on this and drive the D1 HTTP API directly instead, since pinning a production schema to the capitalisation of a keyword fails only in production.

Contributor guide

Open the contributing guide

Research direction

Start in src/d1/migrations.ts at buildMigrationQuery and compare the remote migration path with unstable_splitSqlQuery and normalizeSqlLineEndings. Reproduce the provided lowercase-BEGIN, mixed-case, and CRLF cases with d1 migrations apply --remote, then determine whether the fix belongs in Wrangler or the server-side splitter. Done means remote migrations accept valid trigger bodies without casing or line-ending workarounds.

Written by the indexing model from the issue text.

Assessment

Tech stack
sqlite, typescript
Domain
api, backend, cli
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.