Skip to content

Statement splitter closes a BEGIN ATOMIC body at any identifier ending in end — the closer lacks the boundary check the opener already has #6562

Description

@sergiosalcedojob

Describe the bug

AtomicState.Next ends a BEGIN ATOMIC function body as soon as the last three bytes of the accumulated
buffer are end (case-insensitively), with no identifier-boundary check — while the opener,
isBeginAtomic, checks boundaries on both sides. Because of that asymmetry, an ordinary column or alias
whose name merely ends in those three letters (pending, pending_change, append, legend, …) closes
the body early. The statement is then emitted at the next ;, truncated, and Postgres answers:

ERROR: syntax error at end of input (SQLSTATE 42601)

The migration is valid SQL: psql -f applies the same file without complaint. It fails only through the
CLI (supabase db start, supabase migration up, supabase db push), which makes it invisible to every
check that does not go through the splitter and turns it into a CI-only failure.

To Reproduce

Two files, 131 bytes each, differing in one letter. The first fails, the second applies:

-- FAILS: the body closes inside `pending`, so the statement is cut at the `;` and never terminated
create or replace function public.probe_splitter() returns integer
language sql
immutable
begin atomic
  select 1 as pending;
end;
-- APPLIES: identical, alias renamed so it no longer ends in those three letters
create or replace function public.probe_splitter() returns integer
language sql
immutable
begin atomic
  select 1 as pxnding;
end;

Run either as the only migration in a project and supabase migration up --db-url <any database>. The error
output echoes the statement it sent, and you can see it stop mid-body with no terminator.

Not length-dependent: padding the failing file from 4.5 KB to 35 KB changes nothing, and the two files above
are byte-for-byte the same length.

Expected behavior

An identifier that happens to end in the delimiter keyword is not the delimiter. The body should close only
at a standalone END.

Root cause

In apps/cli-go/pkg/parser/state.go at v2.117.0 (still present in the latest release):

  • isBeginAtomic (≈ L56-77) rejects a match whose neighbouring rune isIdentifierRune — on both sides,
    for ATOMIC and for BEGIN.

  • AtomicState.Next (≈ L197-209) does only:

    window := data[len(data)-len(s.delimiter):]
    if strings.EqualFold(string(window), string(s.delimiter)) {
        return &ReadyState{}
    }

    No boundary check. isIdentifierRune (≈ L79) is right there and already used by the opener, so the fix
    looks like applying the same guard to the byte preceding window when the delimiter is END_ATOMIC.

Two further symptoms of the same line, for completeness

  1. case … end inside an atomic body closes it early. That is Migrations break when a BEGIN ATOMIC statement includes CASE #3474, which was closed by staleness with an
    invitation to /reopen if it still reproduces — it does, on v2.117.0. It is a real keyword, so it is
    arguably a harder call than the identifier case above.
  2. Parentheses only protect until the first ). AtomicState is also used with delimiter: []byte{')'}
    (≈ L45), and a ) returns ReadyState, which collapses the nesting: select coalesce(p_pending, 0);
    applies, while select coalesce((select 0), p_pending); fails. So "wrap it in parentheses" is not a
    reliable workaround.

Double-quoting the identifier (select 1 as "pending";) does work, because the quote state suppresses the
check. Comments are also safe.

Why this is not a duplicate

Happy to fold this into #3474 instead if you would rather keep one thread — I filed it separately because the
plain-identifier case is broader than CASE, and because the asymmetry between opener and closer looks like
the single thing to fix.

Impact

A truncation that produces 42601 is at least loud. One caveat worth flagging: the CLI wraps a migration
file in a transaction, so the usual case rolls back cleanly — but a file carrying its own commit;
before the trigger leaves the earlier half committed with no row written to schema_migrations, i.e. half a
migration applied and unrecorded, and the retry then collides with "already exists".

System information

  • Reproduced on CLI 2.109.1; root cause verified by inspection of the released v2.117.0 source (the
    closer is unchanged).
  • OS: Linux.
  • No --create-ticket id: the reproduction above is self-contained and needs no project telemetry.

Activity

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions