Skip to content

[wrangler] Fix D1 SQL splitter under-splitting migrations with a CASE...END followed by a comma - #15234

Open
mittalpk wants to merge 7 commits into
cloudflare:mainfrom
mittalpk:fix/d1-splitter-case-end-comma
Open

mittalpk wants to merge 7 commits into
cloudflare:mainfrom
mittalpk:fix/d1-splitter-case-end-comma

Conversation

@mittalpk

@mittalpk mittalpk commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #15162

splitSqlIntoStatements() tracks nested BEGIN/CASE blocks so it doesn't split a semicolon that's inside a trigger body or a CASE expression. It detected the end of a block by checking whether END was followed by ; or whitespace — but a CASE used as a value expression (e.g. SET x = CASE ... END, y = 1, which is exactly what the reporter's real migration file does) is legitimately followed directly by a comma. When that happened, the block's END went undetected, leaving the splitter's internal nesting tracker permanently one level too deep for the rest of the file — every remaining statement got silently merged into one giant blob instead of being split correctly.

Reproduced with the reporter's linked repro repo: the 5th migration file (667 lines, ~193 real statements) was being collapsed into 2. Passing that single oversized "statement" through D1's local batch() execution is what produced the reported database table is locked: SQLITE_LOCKED error — confirmed by rebuilding wrangler both with and without the fix and running the exact repro both ways, live.

Fix: broaden the single trailing-character check from [;\s] to any non-identifier character, so END, and END) are recognized the same way END; and END already were.

  • Tests
    • Tests included/updated
  • Public documentation
    • Documentation not necessary because: internal fix to existing SQL-splitting logic; no new command, flag, or user-facing behavior to document beyond the changeset

Devin Review

…...END followed by a comma

isCompoundStatementEnd() only recognized END as closing a compound
statement when the next character was ';' or whitespace. A CASE used
as a value expression (SET x = CASE ... END, y = 1) is legitimately
followed directly by a comma, so that END went undetected, leaving the
splitter's nesting tracker permanently one level too deep for the rest
of the file -- every remaining statement got silently merged into one
oversized blob.

Broadened the check to any non-identifier character, matching what
already worked for ';' and whitespace.

Fixes cloudflare#15162.
@changeset-bot

changeset-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8b91b2c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
wrangler Patch
@cloudflare/vite-plugin Patch
@cloudflare/vitest-plugin Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-project-automation github-project-automation Bot moved this to Untriaged in workers-sdk Aug 17, 2026
@workers-devprod
workers-devprod requested review from a team and jamesopstad and removed request for a team August 17, 2026 09:09
@workers-devprod

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/d1
  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/d1-splitter-case-end-comma.md: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/d1/splitter.test.ts: [@cloudflare/d1 @cloudflare/wrangler]
  • packages/wrangler/src/d1/splitter.ts: [@cloudflare/d1 @cloudflare/wrangler]

@pkg-pr-new

pkg-pr-new Bot commented Aug 17, 2026

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/@cloudflare/autoconfig@15234

@cloudflare/build-output-utils

npm i https://pkg.pr.new/@cloudflare/build-output-utils@15234

@cloudflare/codemods

npm i https://pkg.pr.new/@cloudflare/codemods@15234

@cloudflare/config

npm i https://pkg.pr.new/@cloudflare/config@15234

@cloudflare/containers-shared

npm i https://pkg.pr.new/@cloudflare/containers-shared@15234

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@15234

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@15234

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@15234

miniflare

npm i https://pkg.pr.new/miniflare@15234

@cloudflare/pages-functions

npm i https://pkg.pr.new/@cloudflare/pages-functions@15234

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@15234

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@15234

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@15234

@cloudflare/vitest-plugin

npm i https://pkg.pr.new/@cloudflare/vitest-plugin@15234

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@15234

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@15234

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@15234

wrangler

npm i https://pkg.pr.new/wrangler@15234

commit: 8b91b2c

devin-ai-integration[bot]

This comment was marked as resolved.

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

…ed CASE

isCompoundStatementEnd() was broadened to accept any non-identifier
character after END (to fix cloudflare#15162's comma case), but
isCompoundStatementStart() still required whitespace immediately
before CASE/BEGIN. A parenthesised CASE used as a value expression,
e.g. (CASE ... END), is preceded by '(' with no space -- its start was
never detected, but its END now was. That let an inner CASE's END)
prematurely close an unrelated, already-open compound statement (e.g.
an enclosing trigger's BEGIN ... END), corrupting the split.

Broadened isCompoundStatementStart() the same way, so detection is
symmetric on both sides.

Thanks to Devin Review for catching this.
@workers-devprod

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/d1
  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/d1-splitter-case-end-comma.md: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/d1/splitter.test.ts: [@cloudflare/d1 @cloudflare/wrangler]
  • packages/wrangler/src/d1/splitter.ts: [@cloudflare/d1 @cloudflare/wrangler]

@jamesopstad
jamesopstad requested review from NuroDev and removed request for jamesopstad September 15, 2026 13:42
@workers-devprod

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • @cloudflare/d1
  • @cloudflare/wrangler
Show detailed file reviewers
  • .changeset/d1-splitter-case-end-comma.md: [@cloudflare/wrangler]
  • packages/wrangler/src/tests/d1/splitter.test.ts: [@cloudflare/d1 @cloudflare/wrangler]
  • packages/wrangler/src/d1/splitter.ts: [@cloudflare/d1 @cloudflare/wrangler]

devin-ai-integration[bot]

This comment was marked as resolved.

@NuroDev NuroDev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one issue that needs fixing then good to merge this.

Comment thread packages/wrangler/src/__tests__/d1/splitter.test.ts
Comment thread packages/wrangler/src/d1/splitter.ts Outdated
@github-project-automation github-project-automation Bot moved this from Untriaged to In Review in workers-sdk Sep 17, 2026
…e-end-comma

# Conflicts:
#	packages/wrangler/src/__tests__/d1/splitter.test.ts
isCompoundStatementStart/End used [^A-Za-z0-9_] as the identifier boundary,
but SQLite's own unquoted-identifier syntax allows $ and any code point at
or above U+0080. An identifier like foo$CASE or ENDα was misread as the
BEGIN/CASE/END keyword, corrupting the compound-statement nesting stack for
the rest of the file.

Extract the boundary into a single SQL_IDENTIFIER_CHAR class shared by both
functions so they can't drift out of sync again -- that exact drift (one
regex broadened, the other left narrow) is what caused the CASE/END
regression this PR already fixes.

Precompile both regexes at module scope: isCompoundStatementStart runs on
every character of the input in the main split loop, so constructing a new
RegExp inside the function would recompile it per character.

Adds 2 regression tests. Also resolves the merge conflict with cloudflare#15557's
consumeWhile windowing, which touched the same test file.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 4 new potential issues.

Devin Review

Comment thread packages/wrangler/src/d1/splitter.ts Outdated
Comment thread packages/wrangler/src/d1/splitter.ts Outdated
Comment on lines +278 to +285
const COMPOUND_STATEMENT_START_RE = new RegExp(
`[^${SQL_IDENTIFIER_CHAR}](BEGIN|CASE)\\s$`,
"i"
);
const COMPOUND_STATEMENT_END_RE = new RegExp(
`\\sEND[^${SQL_IDENTIFIER_CHAR}]$`,
"i"
);

@devin-ai-integration devin-ai-integration Bot Sep 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Module constants conflict with review guidance

COMPOUND_STATEMENT_START_RE and COMPOUND_STATEMENT_END_RE are module-level state. REVIEW.md rejects global variables, so maintainers need to confirm whether immutable regex constants are exempt.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are module-private immutable constants, not global mutable state. Wrangler already uses module-level regex constants extensively, and keeping them here avoids recreating the regexes in the per-character path, so I think this is fine to ignore.

Comment thread .changeset/d1-splitter-case-end-comma.md
Comment thread packages/wrangler/src/d1/splitter.ts Outdated
Comment thread packages/wrangler/src/d1/splitter.ts Outdated
Comment on lines +278 to +285
const COMPOUND_STATEMENT_START_RE = new RegExp(
`[^${SQL_IDENTIFIER_CHAR}](BEGIN|CASE)\\s$`,
"i"
);
const COMPOUND_STATEMENT_END_RE = new RegExp(
`\\sEND[^${SQL_IDENTIFIER_CHAR}]$`,
"i"
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are module-private immutable constants, not global mutable state. Wrangler already uses module-level regex constants extensively, and keeping them here avoids recreating the regexes in the per-character path, so I think this is fine to ignore.

…EGIN/CASE/END detection symmetrically

Three related gaps in the boundary logic, all found by review (Devin +
NuroDev) and independently verified before fixing:

1. `new.begin` (a column reference qualified by `.`) was misread as the
   BEGIN keyword, since `.` counted as a valid boundary. SQLite's `.`
   qualifier always introduces an identifier on its right, never a real
   keyword. Excludes `.` from the boundary class via a new
   SQL_STATEMENT_BOUNDARY, used only on the leading side.

2. `CASE(expr)` (no space after CASE) was never recognized as a start,
   since the trailing side still required literal whitespace. Broadened
   to any non-identifier character, mirroring the trailing side of END.

3. A compact `THEN 1 ELSE(0)END` (no space before END) was never
   recognized as an end. This isn't just "unsplit" -- verified live
   that it silently pops the *wrong* stack frame (a later, unrelated
   END instead), merging multiple statements including a trailing
   CREATE INDEX into one.

Adds 3 regression tests, each confirmed failing against unpatched code
via git stash before the fix. Full D1 suite (209 tests) passes, oxlint
and tsc clean.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +299 to +305
const COMPOUND_STATEMENT_START_RE = new RegExp(
`${SQL_STATEMENT_BOUNDARY}(BEGIN|CASE)[^${SQL_IDENTIFIER_CHAR}]$`,
"i"
);
const COMPOUND_STATEMENT_END_RE = new RegExp(
`${SQL_STATEMENT_BOUNDARY}END[^${SQL_IDENTIFIER_CHAR}]$`,
"i"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Qualified keyword names corrupt nesting

A qualified name like case.value makes COMPOUND_STATEMENT_START_RE open a false frame. A name like end.value closes the trigger's frame, splitting its body at the next semicolon.

Learn more

SQLite permits keywords as identifiers where the grammar expects a name. A qualifier dot therefore can follow an identifier named begin, case, or end, just as it can precede one. The trailing boundary currently includes dots, so the stack changes while scanning the qualified name rather than a compound marker.

Example: Inside CREATE TRIGGER t ... BEGIN SELECT end.value; UPDATE items SET x = 1; END;, scanning end. removes the trigger's BEGIN frame. The semicolon after value then splits the trigger into invalid statements.

Recommended fix: Use SQL_STATEMENT_BOUNDARY for both sides of each keyword. This preserves punctuation forms such as CASE( and END, while excluding qualifier dots symmetrically.

Suggested change
const COMPOUND_STATEMENT_START_RE = new RegExp(
`${SQL_STATEMENT_BOUNDARY}(BEGIN|CASE)[^${SQL_IDENTIFIER_CHAR}]$`,
"i"
);
const COMPOUND_STATEMENT_END_RE = new RegExp(
`${SQL_STATEMENT_BOUNDARY}END[^${SQL_IDENTIFIER_CHAR}]$`,
"i"
const COMPOUND_STATEMENT_START_RE = new RegExp(
`${SQL_STATEMENT_BOUNDARY}(BEGIN|CASE)${SQL_STATEMENT_BOUNDARY}$`,
"i"
);
const COMPOUND_STATEMENT_END_RE = new RegExp(
`${SQL_STATEMENT_BOUNDARY}END${SQL_STATEMENT_BOUNDARY}$`,
"i"
);

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

wrangler d1 migrations apply --local fails with SQLITE_LOCKED while remote D1 succeeds

3 participants