Skip to content

Commit 29d3fb0

Browse files
committed
fix(cli): run pipeline-incompatible migration statements outside the transaction (#5139)
Migrations containing CREATE INDEX CONCURRENTLY (and VACUUM, REINDEX CONCURRENTLY, ALTER SYSTEM, CLUSTER) failed with SQLSTATE 25001 because the TS apply wraps every statement in a single BEGIN/COMMIT, and those statements cannot run inside a transaction block. Port the fix from the Go PR #5156 into the native TS apply: detect pipeline-incompatible statements (legacyIsPipelineIncompatible), flush the open batch, run the statement standalone, then resume batching. The history insert goes in the final batch, so the migration is recorded only after every statement succeeds. Migrations without such statements stay a single BEGIN/COMMIT — behaviour is unchanged for them. Shared by migration up/down and declarative sync.
1 parent d4accbf commit 29d3fb0

2 files changed

Lines changed: 239 additions & 22 deletions

File tree

‎apps/cli/src/legacy/shared/legacy-migration-apply.ts‎

Lines changed: 123 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,82 @@ export class LegacyMigrationApplyError extends Data.TaggedError("LegacyMigration
1717
readonlymessage: string;
1818
}>{}
1919

20+
// Byte order mark (U+FEFF) — stripped from the head of a statement like Go does.
21+
constBOM_CODE_POINT=0xfeff;
22+
23+
// Statements that PostgreSQL refuses to run inside a transaction block / extended-query
24+
// pipeline (SQLSTATE 25001). Ports of Go's pattern set in `pkg/migration/file.go`
25+
// (supabase/cli#5156). Matched against the upper-cased, comment-stripped statement.
26+
constCREATE_INDEX_CONCURRENTLY_PATTERN=/^CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY(?:\s|$)/u;
27+
constREINDEX_CONCURRENTLY_PATTERN=/^REINDEX(?:\s|\().*\sCONCURRENTLY(?:\s|$)/u;
28+
constVACUUM_PATTERN=/^VACUUM(?:\s|\(|$)/u;
29+
constALTER_SYSTEM_PATTERN=/^ALTER\s+SYSTEM(?:\s|$)/u;
30+
constCLUSTER_PATTERN=/^CLUSTER(?:\s|$)/u;
31+
32+
/**
33+
* Strips a leading BOM, whitespace, and SQL line (`--`) and block comments from the
34+
* front of a statement so the keyword check below sees the real first token.
35+
* Port of Go's `trimLeadingSQLComments` (`pkg/migration/file.go`, supabase/cli#5156).
36+
*/
37+
constlegacyTrimLeadingSqlComments=(sql: string): string=>{
38+
// Go's `TrimLeftFunc` drops a leading BOM together with whitespace; strip the BOM
39+
// via its code point so no irregular whitespace lands in the source.
40+
lettrimmed=sql.replace(/^[\t\n\r]+/u,"");
41+
while(trimmed.charCodeAt(0)===BOM_CODE_POINT){
42+
trimmed=trimmed.slice(1).replace(/^[\t\n\r]+/u,"");
43+
}
44+
for(;;){
45+
if(trimmed.startsWith("--")){
46+
constidx=trimmed.indexOf("\n");
47+
if(idx<0)return"";
48+
trimmed=trimmed.slice(idx+1).replace(/^[\t\n\r]+/u,"");
49+
}elseif(trimmed.startsWith("/*")){
50+
constidx=trimmed.indexOf("*/");
51+
if(idx<0)returntrimmed;
52+
trimmed=trimmed.slice(idx+2).replace(/^[\t\n\r]+/u,"");
53+
}else{
54+
returntrimmed.trim();
55+
}
56+
}
57+
};
58+
59+
/**
60+
* Whether a migration statement cannot run inside a transaction block — `CREATE
61+
* [UNIQUE] INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`,
62+
* `CLUSTER`. Such statements fail with SQLSTATE 25001 inside the `BEGIN`/`COMMIT`
63+
* that wraps a migration, so `legacyApplyMigrationFile` runs them standalone.
64+
* Port of Go's `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156).
65+
*/
66+
exportconstlegacyIsPipelineIncompatible=(sql: string): boolean=>{
67+
constupper=legacyTrimLeadingSqlComments(sql).toUpperCase();
68+
return(
69+
CREATE_INDEX_CONCURRENTLY_PATTERN.test(upper)||
70+
REINDEX_CONCURRENTLY_PATTERN.test(upper)||
71+
VACUUM_PATTERN.test(upper)||
72+
ALTER_SYSTEM_PATTERN.test(upper)||
73+
CLUSTER_PATTERN.test(upper)
74+
);
75+
};
76+
77+
/** A buffered statement awaiting the next batch flush; `version` is the history insert. */
78+
typeLegacyBatchItem=
79+
|{readonlykind: "exec";readonlysql: string}
80+
|{readonlykind: "version"};
81+
2082
/**
2183
* Applies a single migration file to the connected database and records it in
2284
* `supabase_migrations.schema_migrations`. Mirrors Go's `migration.ApplyMigrations`
2385
* for one file (`pkg/migration/apply.go` + `(*MigrationFile).ExecBatch`): create
2486
* the history table, `RESET ALL`, then run the file's statements + the history
25-
* insert atomically. The whole file is one transaction (Go's `ExecBatch` is
26-
* implicitly transactional); on failure the transaction is rolled back.
87+
* insert.
88+
*
89+
* Statements run inside a `BEGIN`/`COMMIT` batch, except pipeline-incompatible ones
90+
* (`legacyIsPipelineIncompatible` — `CREATE INDEX CONCURRENTLY`, `VACUUM`, …) which
91+
* cannot run in a transaction block: the batch is flushed (committed), the statement
92+
* runs standalone, then batching resumes — mirroring Go's `ExecBatch` flush logic
93+
* (supabase/cli#5156). The history insert goes in the final batch, so the migration
94+
* is recorded only after every statement succeeds. A file with no such statements is
95+
* a single `BEGIN`/`COMMIT` around everything, identical to the pre-fix behaviour.
2796
*
2897
* `mapError` lets the caller tag the failure (e.g. `LegacyDeclarativeApplyError`).
2998
*/
@@ -44,8 +113,8 @@ export const legacyApplyMigrationFile = <E>(
44113

45114
yield*legacyCreateMigrationTable(session);
46115
yield*session.exec("RESET ALL");
47-
yield*session.exec("BEGIN");
48-
// Mirror Go's `MigrationFile.ExecBatch` error context (`pkg/migration/file.go:88-113`):
116+
117+
// Mirror Go's `MigrationFile.ExecBatch` error context (`pkg/migration/file.go`):
49118
// on a failed statement, append `At statement: <index>` and the statement text so the
50119
// error (and the debug bundle) point at the exact failing SQL. (Go also adds a caret /
51120
// pgErr.Detail / extension-type hint, which need the driver SQLSTATE the session does
@@ -56,26 +125,59 @@ export const legacyApplyMigrationFile = <E>(
56125
: String(e);
57126
constatStatement=(e: unknown,index: number,stat: string)=>
58127
newError(`${errMessage(e)}\nAt statement: ${index}\n${stat}`);
59-
constbody=Effect.gen(function*(){
60-
for(leti=0;i<statements.length;i++){
61-
conststatement=statements[i]??"";
128+
129+
// `executed` is the global statement index of the next statement to run, so the
130+
// error context stays accurate across flushed batches and standalone statements
131+
// (Go threads the same counter through `ExecBatch`).
132+
letpending: ReadonlyArray<LegacyBatchItem>=[];
133+
letexecuted=0;
134+
135+
constflushBatch=Effect.gen(function*(){
136+
if(pending.length===0)return;
137+
constitems=pending;
138+
pending=[];
139+
constbase=executed;
140+
constbody=Effect.gen(function*(){
141+
for(const[offset,item]ofitems.entries()){
142+
constindex=base+offset;
143+
if(item.kind==="version"){
144+
// Go defaults to the version-insert statement when all listed statements succeed.
145+
yield*session
146+
.query(INSERT_MIGRATION_VERSION,[version,name,statements])
147+
.pipe(
148+
Effect.mapError((cause)=>atStatement(cause,index,INSERT_MIGRATION_VERSION)),
149+
);
150+
}else{
151+
yield*session
152+
.exec(item.sql)
153+
.pipe(Effect.mapError((cause)=>atStatement(cause,index,item.sql)));
154+
}
155+
}
156+
yield*session.exec("COMMIT");
157+
});
158+
yield*session.exec("BEGIN");
159+
yield*body.pipe(Effect.tapError(()=>session.exec("ROLLBACK").pipe(Effect.ignore)));
160+
executed+=items.length;
161+
});
162+
163+
for(conststatementofstatements){
164+
if(legacyIsPipelineIncompatible(statement)){
165+
// Flush the open batch, then run the incompatible statement on its own (no
166+
// surrounding transaction) so PostgreSQL accepts it.
167+
yield*flushBatch;
168+
constindex=executed;
62169
yield*session
63170
.exec(statement)
64-
.pipe(Effect.mapError((cause)=>atStatement(cause,i,statement)));
65-
}
66-
if(version.length>0){
67-
// Go defaults to the version-insert statement when all listed statements succeed.
68-
yield*session
69-
.query(INSERT_MIGRATION_VERSION,[version,name,statements])
70-
.pipe(
71-
Effect.mapError((cause)=>
72-
atStatement(cause,statements.length,INSERT_MIGRATION_VERSION),
73-
),
74-
);
171+
.pipe(Effect.mapError((cause)=>atStatement(cause,index,statement)));
172+
executed+=1;
173+
}else{
174+
pending=[...pending,{kind: "exec",sql: statement}];
75175
}
76-
yield*session.exec("COMMIT");
77-
});
78-
yield*body.pipe(Effect.tapError(()=>session.exec("ROLLBACK").pipe(Effect.ignore)));
176+
}
177+
if(version.length>0){
178+
pending=[...pending,{kind: "version"}];
179+
}
180+
yield*flushBatch;
79181
}).pipe(
80182
Effect.mapError((error)=>
81183
mapError(

‎apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts‎

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import { describe, expect, it } from "@effect/vitest";
66
import{Data,Effect,Exit,FileSystem,Path}from"effect";
77

88
importtype{LegacyDbSession}from"./legacy-db-connection.service.ts";
9-
import{legacyApplyMigrationFile}from"./legacy-migration-apply.ts";
9+
import{
10+
legacyApplyMigrationFile,
11+
legacyIsPipelineIncompatible,
12+
}from"./legacy-migration-apply.ts";
1013

1114
classTestErrorextendsData.TaggedError("TestError")<{readonlymessage: string}>{}
1215

@@ -103,4 +106,116 @@ describe("legacyApplyMigrationFile", () => {
103106
),
104107
);
105108
});
109+
110+
it.effect("runs a pipeline-incompatible statement outside the surrounding transaction",()=>{
111+
constdir=mkdtempSync(join(tmpdir(),"legacy-apply-"));
112+
constfile=join(dir,"20240101120000_add_index.sql");
113+
writeFileSync(
114+
file,
115+
"create table a (id int);\nCREATE INDEX CONCURRENTLY a_idx ON a(id);\nALTER TABLE a ENABLE ROW LEVEL SECURITY;",
116+
);
117+
const{ session, calls }=fakeSession();
118+
returnrun(session,file).pipe(
119+
Effect.tap(()=>
120+
Effect.sync(()=>{
121+
constexecs=calls.filter((c)=>c.kind==="exec").map((c)=>c.sql);
122+
constconcurrently="CREATE INDEX CONCURRENTLY a_idx ON a(id)";
123+
expect(execs).toContain(concurrently);
124+
// The CONCURRENTLY statement must not run inside an open transaction, or
125+
// PostgreSQL rejects it (SQLSTATE 25001). The batch is flushed first, so the
126+
// BEGIN/COMMIT counts before it must balance (no open transaction).
127+
constbefore=execs.slice(0,execs.indexOf(concurrently));
128+
expect(before.filter((s)=>s==="BEGIN").length).toBe(
129+
before.filter((s)=>s==="COMMIT").length,
130+
);
131+
// The compatible statements still ran inside a transaction...
132+
expect(before).toContain("BEGIN");
133+
expect(before).toContain("COMMIT");
134+
// ...and the trailing compatible statement reopens a transaction after it.
135+
constafter=execs.slice(execs.indexOf(concurrently)+1);
136+
expect(after).toContain("BEGIN");
137+
expect(after.indexOf("ALTER TABLE a ENABLE ROW LEVEL SECURITY")).toBeGreaterThanOrEqual(
138+
0,
139+
);
140+
// The migration is still recorded once every statement succeeds.
141+
constinsert=calls.find((c)=>c.kind==="query");
142+
expect(insert?.params?.[0]).toBe("20240101120000");
143+
rmSync(dir,{recursive: true,force: true});
144+
}),
145+
),
146+
);
147+
});
148+
149+
it.effect("reports a pipeline-incompatible statement failure with its statement index",()=>{
150+
constdir=mkdtempSync(join(tmpdir(),"legacy-apply-"));
151+
constfile=join(dir,"20240101120000_add_index.sql");
152+
writeFileSync(file,"create table a (id int);\nCREATE INDEX CONCURRENTLY a_idx ON a(id);");
153+
const{ session, calls }=fakeSession({failOn: "CONCURRENTLY"});
154+
returnrun(session,file).pipe(
155+
Effect.exit,
156+
Effect.tap((exit)=>
157+
Effect.sync(()=>{
158+
expect(Exit.isFailure(exit)).toBe(true);
159+
if(Exit.isFailure(exit)){
160+
constmsg=JSON.stringify(exit.cause);
161+
// Index 1: the leading `create table a` (index 0) committed in its own batch first.
162+
expect(msg).toContain("At statement: 1");
163+
expect(msg).toContain("CREATE INDEX CONCURRENTLY a_idx ON a(id)");
164+
}
165+
// The migration version is not recorded when a statement fails.
166+
expect(calls.some((c)=>c.kind==="query")).toBe(false);
167+
rmSync(dir,{recursive: true,force: true});
168+
}),
169+
),
170+
);
171+
});
172+
});
173+
174+
describe("legacyIsPipelineIncompatible",()=>{
175+
// Mirrors Go's `TestIsPipelineIncompatible` (`pkg/migration/file_test.go`, supabase/cli#5156).
176+
constcases: ReadonlyArray<readonly[string,string,boolean]>=[
177+
[
178+
"create index concurrently",
179+
"CREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)",
180+
true,
181+
],
182+
[
183+
"create unique index concurrently",
184+
"CREATE UNIQUE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)",
185+
true,
186+
],
187+
[
188+
"create index concurrently after comments",
189+
"-- cannot run in a transaction\n/* generated */\nCREATE INDEX CONCURRENTLY widgets_id_idx ON public.widgets(id)",
190+
true,
191+
],
192+
["reindex table concurrently","REINDEX TABLE CONCURRENTLY public.widgets",true],
193+
[
194+
"reindex with options concurrently",
195+
"REINDEX (VERBOSE) INDEX CONCURRENTLY widgets_id_idx",
196+
true,
197+
],
198+
["vacuum bare","VACUUM",true],
199+
["vacuum with options","VACUUM (FULL, ANALYZE) public.widgets",true],
200+
["alter system","ALTER SYSTEM SET wal_level = 'logical'",true],
201+
["cluster","CLUSTER public.widgets USING widgets_id_idx",true],
202+
[
203+
"lower-case create index concurrently",
204+
"create index concurrently widgets_id_idx on public.widgets(id)",
205+
true,
206+
],
207+
["leading whitespace before concurrently"," CREATE INDEX CONCURRENTLY a_idx ON a(id)",true],
208+
// Negatives — compatible statements that must keep running inside the batch transaction.
209+
["plain create index","CREATE INDEX widgets_id_idx ON public.widgets(id)",false],
210+
["create table","create table public.widgets(id bigint primary key)",false],
211+
["reindex without concurrently","REINDEX TABLE public.widgets",false],
212+
["vacuum-prefixed identifier","VACUUMING analytics",false],
213+
["concurrently as a column name","CREATE TABLE t (concurrently int)",false],
214+
["insert","INSERT INTO public.widgets VALUES (1)",false],
215+
["cluster-prefixed identifier","CLUSTERED",false],
216+
];
217+
218+
it.each(cases)("%s",(_name,sql,want)=>{
219+
expect(legacyIsPipelineIncompatible(sql)).toBe(want);
220+
});
106221
});

0 commit comments

Comments
 (0)