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)
- 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
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