From 30ba92cbd5aae4fb3a6da093c1f7b79b89b0011a Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 27 Aug 2026 04:35:09 +0530 Subject: [PATCH 01/33] fix(cli): wrap pipelined migration batches in an explicit transaction (CLI-2261) --- .../shared/legacy-db-connection.service.ts | 6 +- ...y-db-connection.sql-pg.integration.test.ts | 98 +++++++++++++++- .../legacy-db-connection.sql-pg.layer.ts | 96 +++++++++++---- .../legacy-db-connection.sql-pg.unit.test.ts | 111 +++++++++++++++++- .../legacy/shared/legacy-migration-apply.ts | 6 +- 5 files changed, 280 insertions(+), 37 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts index e19ce440b8..f42246ab90 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts @@ -108,8 +108,10 @@ export interface LegacyDbSession { /** Run a single SQL statement, ignoring any returned rows. */ readonly exec: (sql: string) => Effect.Effect; /** - * Run statements as one extended-protocol batch with a single final Sync. - * On failure, {@link LegacyDbExecError.statementIndex} is the number of + * Run statements as one extended-protocol batch inside a single explicit + * transaction, with a single final Sync — a bare pipeline is not a transaction + * block (supabase/cli#6347). On failure the batch is rolled back and + * {@link LegacyDbExecError.statementIndex} is the number of the caller's * statements that completed before the error. * * A batch runs on its own pooled connection, which the driver checks out per diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts index f872fe031f..0f479216db 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts @@ -166,6 +166,7 @@ interface FakeBatchServerState { readonly frameTypes: Array; readonly statements: Array; readonly params: Array>; + readonly simpleQueries: Array; syncs: number; } @@ -177,6 +178,8 @@ const fakeBatchServer = ( readonly emptyAt?: number; /** Never answer an extended-protocol frame, so a batch hangs until interrupted. */ readonly stall?: boolean; + readonly stallRollback?: boolean; + readonly destroyOnRollback?: boolean; /** Drop the connection on the first Sync, so a batch dies mid-flight. */ readonly destroyOnFirstSync?: boolean; } = {}, @@ -191,6 +194,7 @@ const fakeBatchServer = ( frameTypes: [], statements: [], params: [], + simpleQueries: [], syncs: 0, }; const sockets: Array = []; @@ -223,6 +227,13 @@ const fakeBatchServer = ( const body = pending.subarray(5, length + 1); pending = pending.subarray(length + 1); if (type === "Q") { + const sql = body.toString("utf8", 0, body.length - 1); + state.simpleQueries.push(sql); + if (options.stallRollback === true && sql === "ROLLBACK") continue; + if (options.destroyOnRollback === true && sql === "ROLLBACK") { + socket.destroy(); + return; + } socket.write(Buffer.concat([commandComplete("SELECT 1"), READY_FOR_QUERY])); continue; } @@ -554,9 +565,9 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { Effect.ensuring(Effect.sync(server.close)), ); - it.live("sends every statement and parameter set before one Sync", () => + it.live("sends every statement and parameter set inside one BEGIN/COMMIT before one Sync", () => Effect.gen(function* () { - const server = yield* Effect.promise(() => fakeBatchServer({ emptyAt: 1 })); + const server = yield* Effect.promise(() => fakeBatchServer({ emptyAt: 2 })); const values = ["plain", 'quote"', "slash\\", "comma,", "{brace}", "line\nbreak", "NULL", ""]; yield* runWithBatchServer(server, (session) => session.execBatch([ @@ -569,11 +580,14 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { ]), ); expect(server.state.statements).toEqual([ + "BEGIN", "SELECT 1", "-- comment only", "INSERT INTO history(version, name, statements) VALUES($1, $2, $3)", + "COMMIT", ]); expect(server.state.params).toEqual([ + [], [], [], [ @@ -581,6 +595,7 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { "name\\two", '{"plain","quote\\\"","slash\\\\","comma,","{brace}","line\nbreak","NULL",""}', ], + [], ]); expect(server.state.frameTypes).toEqual([ "P", @@ -595,15 +610,24 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { "B", "D", "E", + "P", + "B", + "D", + "E", + "P", + "B", + "D", + "E", "S", ]); expect(server.state.syncs).toBe(1); + expect(server.state.simpleQueries).not.toContain("ROLLBACK"); }), ); it.live("maps a later parse failure to its statement and keeps its local position", () => Effect.gen(function* () { - const server = yield* Effect.promise(() => fakeBatchServer({ emptyAt: 1, failAt: 2 })); + const server = yield* Effect.promise(() => fakeBatchServer({ emptyAt: 2, failAt: 3 })); yield* runWithBatchServer(server, (session) => Effect.gen(function* () { const error = asBatchExecError( @@ -624,7 +648,7 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { it.live("maps a position-less runtime failure from completed commands", () => Effect.gen(function* () { - const server = yield* Effect.promise(() => fakeBatchServer({ failExecuteAt: 1 })); + const server = yield* Effect.promise(() => fakeBatchServer({ failExecuteAt: 2 })); yield* runWithBatchServer(server, (session) => session .execBatch([{ sql: "SELECT 1" }, { sql: "INSERT duplicate" }, { sql: "SELECT 3" }]) @@ -662,6 +686,72 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { }), ); + it.live("rolls a failed batch's transaction back before the pooled client is reused", () => + Effect.gen(function* () { + const server = yield* Effect.promise(() => fakeBatchServer({ failExecuteAt: 3 })); + yield* runWithBatchServer(server, (session) => + Effect.gen(function* () { + yield* session + .execBatch([{ sql: "SELECT 1" }, { sql: "SELECT 2" }, { sql: "INSERT duplicate" }]) + .pipe(Effect.flip); + const sockets = server.sockets.length; + yield* session.execBatch([{ sql: "SELECT 3" }]); + expect(server.sockets.length).toBe(sockets); + }), + ); + expect(server.state.simpleQueries).toContain("ROLLBACK"); + expect(server.state.syncs).toBe(2); + }), + ); + + it.live("absorbs a socket death during the release-path rollback instead of crashing", () => + Effect.gen(function* () { + const server = yield* Effect.promise(() => + fakeBatchServer({ failExecuteAt: 3, destroyOnRollback: true }), + ); + yield* runWithBatchServer(server, (session) => + Effect.gen(function* () { + yield* session + .execBatch([{ sql: "SELECT 1" }, { sql: "SELECT 2" }, { sql: "INSERT duplicate" }]) + .pipe(Effect.flip); + yield* session.execBatch([{ sql: "SELECT 3" }]).pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(10), + orElse: () => Effect.die("the batch after a dead-rollback socket never settled"), + }), + ); + expect(server.state.simpleQueries).toContain("ROLLBACK"); + }), + ); + }), + ); + + it.live( + "bounds a stalled failed-batch rollback and discards the client instead of reusing it", + () => + Effect.gen(function* () { + const server = yield* Effect.promise(() => + fakeBatchServer({ failExecuteAt: 3, stallRollback: true }), + ); + yield* runWithBatchServer(server, (session) => + Effect.gen(function* () { + const before = server.sockets.length; + yield* session + .execBatch([{ sql: "SELECT 1" }, { sql: "SELECT 2" }, { sql: "INSERT duplicate" }]) + .pipe(Effect.flip); + yield* session.execBatch([{ sql: "SELECT 3" }]).pipe( + Effect.timeoutOrElse({ + duration: Duration.seconds(10), + orElse: () => Effect.die("the batch after a stalled rollback never settled"), + }), + ); + expect(server.state.simpleQueries).toContain("ROLLBACK"); + expect(server.sockets.length).toBeGreaterThan(before); + }), + ); + }), + ); + it.live("fails a batch whose connection drops after it was written, then recovers", () => // A socket dropped after the batch was written must fail that batch and must not leave // the client to be handed to the next one. diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index a0cd4c1fbe..29ac1b0963 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import * as net from "node:net"; import type { ConnectionOptions } from "node:tls"; import { PgClient } from "@effect/sql-pg"; -import { Cause, Duration, Effect, Exit, Layer, Scope } from "effect"; +import { Cause, Duration, Effect, Exit, Layer, Option, Scope } from "effect"; import * as Reactivity from "effect/unstable/reactivity/Reactivity"; import { ConnectionError, SqlError } from "effect/unstable/sql/SqlError"; // `pg` is also `@effect/sql-pg`'s transitive driver; we depend on it directly for @@ -224,7 +224,13 @@ const LEGACY_DB_KEEPALIVE_IDLE_MILLIS = 300_000; */ export function legacyBatchFailureError( error: Error, - batch: { readonly completed: number; readonly outcome: LegacyBatchOutcome } | undefined, + batch: + | { + readonly completed: number; + readonly outcome: LegacyBatchOutcome; + readonly began?: boolean; + } + | undefined, isLocal: boolean, ): LegacyDbExecError | LegacyDbConnectError { if (batch === undefined || batch.outcome === "unsent") { @@ -236,8 +242,15 @@ export function legacyBatchFailureError( }); } const mapped = legacyToExecError(error); + // A lost connection (including a FATAL termination) is not a BEGIN failure. + const beganFailed = + batch.outcome === "submitted" && + batch.began === false && + legacyExtractPgServerError(error)?.severity === "ERROR"; return new LegacyDbExecError({ - message: mapped.message, + message: beganFailed + ? `failed to begin the batch transaction: ${mapped.message}` + : mapped.message, code: mapped.code, detail: mapped.detail, position: mapped.position, @@ -251,10 +264,12 @@ export function legacyBatchFailureError( * socket is already gone, so the next checkout would write into the same dead connection. * * A batch that WAS written keeps its client: a statement failure should not cost a redial and - * a fresh step-down on a single-connection pool. Recovering from a socket that died after the - * write is left to pg-pool, which drops a released client whose private `_queryable` flag is - * false — so that is the behavior to re-check if a pg-pool bump ever breaks the recovery this - * layer's integration tests assert. + * a fresh step-down on a single-connection pool. The keep is conditional on the release + * path's rollback — one that fails or times out discards the client after all. Recovering + * from a socket that died after the write is additionally backstopped by pg-pool, which + * drops a released client whose private `_queryable` flag is false — so that is the behavior + * to re-check if a pg-pool bump ever breaks the recovery this layer's integration tests + * assert. */ export function legacyShouldDiscardBatchClient( batch: { readonly outcome: LegacyBatchOutcome } | undefined, @@ -282,6 +297,7 @@ export class LegacyPgBatchQuery implements Pg.Submittable { callback: (error: Error | undefined) => void; completed = 0; outcome: LegacyBatchOutcome = "unsent"; + began = false; constructor( statements: ReadonlyArray, @@ -301,7 +317,12 @@ export class LegacyPgBatchQuery implements Pg.Submittable { let started = false; connection.stream.cork?.(); try { - for (const { sql, params } of this.statements) { + // A bare pipeline is not a transaction block (supabase/cli#6347). + for (const { sql, params } of [ + { sql: "BEGIN", params: [] }, + ...this.statements, + { sql: "COMMIT", params: [] }, + ]) { started = true; connection.parse({ name: "", text: sql, types: [] }, true); connection.bind({ portal: "", statement: "", values: [...params] }, true); @@ -332,11 +353,22 @@ export class LegacyPgBatchQuery implements Pg.Submittable { handlePortalSuspended(): void {} handleCommandComplete(): void { - this.completed += 1; + this.recordCompletion(); } handleEmptyQuery(): void { - this.completed += 1; + this.recordCompletion(); + } + + // BEGIN completes first and COMMIT only after every statement; neither counts. + private recordCompletion(): void { + if (!this.began) { + this.began = true; + return; + } + if (this.completed < this.statements.length) { + this.completed += 1; + } } handleCopyInResponse(connection: Pg.Connection): void { @@ -1085,12 +1117,17 @@ const connect = ( const execBatch = (statements: ReadonlyArray) => { if (statements.length === 0) return Effect.void; let batchQuery: LegacyPgBatchQuery | undefined; + // Spans the whole checkout: an unlistened 'error' kills the process (see + // acquireRawClient) and pg-pool detaches its own handler while checked out. + const onConnectionError = () => {}; return Effect.acquireUseRelease( - Effect.interruptible(acquireBatchClient), - (activeClient) => { - const onConnectionError = () => {}; - activeClient.on("error", onConnectionError); - return Effect.callback((resume) => { + Effect.interruptible(acquireBatchClient).pipe( + Effect.tap((activeClient) => + Effect.sync(() => activeClient.on("error", onConnectionError)), + ), + ), + (activeClient) => + Effect.callback((resume) => { let done = false; const finish = (error: Error | undefined) => { if (done) return; @@ -1110,16 +1147,29 @@ const connect = ( return Effect.sync(() => { done = true; }); - }).pipe( - Effect.ensuring( - Effect.sync(() => activeClient.removeListener("error", onConnectionError)), - ), - ); - }, + }), (activeClient, exit) => - Effect.sync(() => { + Effect.suspend(() => { const discard = legacyShouldDiscardBatchClient(batchQuery, exit); - activeClient.release(discard ? new Error("batch connection discarded") : undefined); + const release = (broken: boolean) => + Effect.sync(() => { + activeClient.release(broken ? new Error("batch connection discarded") : undefined); + activeClient.removeListener("error", onConnectionError); + }); + if (discard || !Exit.isFailure(exit) || batchQuery?.outcome !== "submitted") { + return release(discard); + } + // Roll the aborted transaction back before the client is reused (25P02), + // bounded because this release step is uninterruptible. + return Effect.promise(() => + activeClient.query("ROLLBACK").then( + () => true, + () => false, + ), + ).pipe( + Effect.timeoutOption(1000), + Effect.flatMap((rolledBack) => release(!Option.getOrElse(rolledBack, () => false))), + ); }), ); }; diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts index 6211b4050c..371407da2c 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts @@ -608,7 +608,9 @@ describe("LegacyPgBatchQuery.submit", () => { frames, connection: { stream, - parse: record("parse"), + parse: (query: { text: string }) => { + frames.push(`parse(${query.text})`); + }, bind: record("bind"), describe: record("describe"), execute: record("execute"), @@ -638,10 +640,26 @@ describe("LegacyPgBatchQuery.submit", () => { "the connection's socket became unwritable while the batch was flushing", ); expect(batch.outcome).toBe("unsent"); - expect(frames).toEqual(["cork", "parse", "bind", "describe", "execute", "sync", "uncork"]); + expect(frames).toEqual([ + "cork", + "parse(BEGIN)", + "bind", + "describe", + "execute", + "parse(select 1)", + "bind", + "describe", + "execute", + "parse(COMMIT)", + "bind", + "describe", + "execute", + "sync", + "uncork", + ]); }); - it("writes parse/bind/describe/execute per statement and one sync while writable", () => { + it("brackets the statements in BEGIN/COMMIT and writes one sync while writable", () => { const { connection, frames } = fakeConnection(true); const batch = new LegacyPgBatchQuery([{ sql: "select 1" }, { sql: "select 2" }], () => {}); @@ -649,11 +667,19 @@ describe("LegacyPgBatchQuery.submit", () => { expect(frames).toEqual([ "cork", - "parse", + "parse(BEGIN)", "bind", "describe", "execute", - "parse", + "parse(select 1)", + "bind", + "describe", + "execute", + "parse(select 2)", + "bind", + "describe", + "execute", + "parse(COMMIT)", "bind", "describe", "execute", @@ -662,6 +688,18 @@ describe("LegacyPgBatchQuery.submit", () => { ]); expect(batch.outcome).toBe("submitted"); }); + + it("counts neither BEGIN's nor COMMIT's completion toward the statement index", () => { + const batch = new LegacyPgBatchQuery([{ sql: "select 1" }, { sql: "select 2" }], () => {}); + + batch.handleCommandComplete(); + expect(batch.completed).toBe(0); + batch.handleCommandComplete(); + batch.handleEmptyQuery(); + expect(batch.completed).toBe(2); + batch.handleCommandComplete(); + expect(batch.completed).toBe(2); + }); }); describe("legacyBatchFailureError", () => { @@ -737,6 +775,69 @@ describe("legacyBatchFailureError", () => { expect(error).toMatchObject({ message: "Error: serialization blew up", statementIndex: 0 }); }); + it("names the transaction start when the server rejected the batch before BEGIN completed", () => { + const beginRejected = new SqlError({ + reason: new SqlSyntaxError({ + cause: Object.assign(new Error("canceling statement due to statement timeout"), { + severity: "ERROR", + code: "57014", + }), + message: "Failed to execute statement", + operation: "execute", + }), + }); + const error = legacyBatchFailureError( + beginRejected, + { completed: 0, outcome: "submitted", began: false }, + true, + ); + + expect(error._tag).toBe("LegacyDbExecError"); + expect(error).toMatchObject({ + message: + "failed to begin the batch transaction: " + + "ERROR: canceling statement due to statement timeout (SQLSTATE 57014)", + statementIndex: 0, + }); + + const lost = legacyBatchFailureError( + new Error("Connection terminated unexpectedly"), + { completed: 0, outcome: "submitted", began: false }, + true, + ); + expect(lost._tag).toBe("LegacyDbExecError"); + expect(lost.message).toBe("Error: Connection terminated unexpectedly"); + + const terminated = legacyBatchFailureError( + new SqlError({ + reason: new SqlSyntaxError({ + cause: Object.assign(new Error("terminating connection due to idle-session timeout"), { + severity: "FATAL", + code: "57P05", + }), + message: "Failed to execute statement", + operation: "execute", + }), + }), + { completed: 0, outcome: "submitted", began: false }, + true, + ); + expect(terminated._tag).toBe("LegacyDbExecError"); + expect(terminated.message).toBe( + "FATAL: terminating connection due to idle-session timeout (SQLSTATE 57P05)", + ); + + const poisoned = legacyBatchFailureError( + beginRejected, + { completed: 0, outcome: "poisoned", began: false }, + true, + ); + expect(poisoned._tag).toBe("LegacyDbExecError"); + expect(poisoned.message).toBe( + "ERROR: canceling statement due to statement timeout (SQLSTATE 57014)", + ); + }); + it("keeps server-error mapping and the completed count for a statement failure", () => { const error = legacyBatchFailureError( new SqlError({ diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 806973a2e3..29304aa252 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -98,7 +98,7 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * Whether a migration statement cannot run inside a transaction block — `CREATE * [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, * `VACUUM`, `ALTER SYSTEM`, `CLUSTER`. Such statements fail with SQLSTATE 25001 - * inside the implicit transaction + * inside the transaction * created by a migration batch, so `execMigrationBatch` runs them standalone. * Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156). */ @@ -531,8 +531,8 @@ const formattedExecBatchDbError = (error: unknown): LegacyDbExecError | undefine /** * Runs a single migration/seed file's statements (plus the optional history insert). - * Statements run inside an implicitly transactional extended-protocol batch, - * except pipeline-incompatible ones + * Statements run inside an explicitly transactional extended-protocol batch + * (supabase/cli#6347), except pipeline-incompatible ones * (`legacyIsPipelineIncompatible` — `CREATE INDEX CONCURRENTLY`, `VACUUM`, …) which * cannot run in a transaction block: the open batch is flushed (committed), the * statement runs standalone, then batching resumes (supabase/cli#5156). The history From 7a718651a9fd4cc006751681d4909cbcfde8bd49 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:27:10 +0530 Subject: [PATCH 02/33] fix: run transaction-prohibited ddl standalone --- .../src/legacy/shared/legacy-migration-apply.ts | 17 +++++++++++++---- .../shared/legacy-migration-apply.unit.test.ts | 8 ++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 29304aa252..d9088d9085 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -64,6 +64,9 @@ const REINDEX_CONCURRENTLY_PATTERN = /^REINDEX(?:\s|\().*\sCONCURRENTLY(?:\s|$)/ const VACUUM_PATTERN = /^VACUUM(?:\s|\(|$)/u; const ALTER_SYSTEM_PATTERN = /^ALTER\s+SYSTEM(?:\s|$)/u; const CLUSTER_PATTERN = /^CLUSTER(?:\s|$)/u; +const DATABASE_DDL_PATTERN = /^(?:CREATE|DROP)\s+DATABASE(?:\s|$)/u; +const TABLESPACE_DDL_PATTERN = /^(?:CREATE|DROP)\s+TABLESPACE(?:\s|$)/u; +const REINDEX_DATABASE_PATTERN = /^REINDEX(?:\s+\([^)]*\))?\s+(?:DATABASE|SYSTEM)(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; @@ -97,10 +100,13 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { /** * Whether a migration statement cannot run inside a transaction block — `CREATE * [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, - * `VACUUM`, `ALTER SYSTEM`, `CLUSTER`. Such statements fail with SQLSTATE 25001 - * inside the transaction + * `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, `CREATE`/`DROP DATABASE`, + * `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`. Such statements fail with + * SQLSTATE 25001 inside the transaction * created by a migration batch, so `execMigrationBatch` runs them standalone. - * Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156). + * Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156), + * extended with the remaining statement kinds PostgreSQL refuses inside the + * explicit transaction the batch runs in since supabase/cli#6347. */ export const legacyIsPipelineIncompatible = (sql: string): boolean => { const upper = legacyTrimLeadingSqlComments(sql).toUpperCase(); @@ -110,7 +116,10 @@ export const legacyIsPipelineIncompatible = (sql: string): boolean => { REINDEX_CONCURRENTLY_PATTERN.test(upper) || VACUUM_PATTERN.test(upper) || ALTER_SYSTEM_PATTERN.test(upper) || - CLUSTER_PATTERN.test(upper) + CLUSTER_PATTERN.test(upper) || + DATABASE_DDL_PATTERN.test(upper) || + TABLESPACE_DDL_PATTERN.test(upper) || + REINDEX_DATABASE_PATTERN.test(upper) ); }; diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 14b75f1d73..53f89428e2 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1116,6 +1116,14 @@ describe("legacyIsPipelineIncompatible", () => { ["vacuum with options", "VACUUM (FULL, ANALYZE) public.widgets", true], ["alter system", "ALTER SYSTEM SET wal_level = 'logical'", true], ["cluster", "CLUSTER public.widgets USING widgets_id_idx", true], + ["create database", "CREATE DATABASE demo", true], + ["drop database", "DROP DATABASE IF EXISTS demo", true], + ["create tablespace", "CREATE TABLESPACE ts LOCATION '/tmp/ts'", true], + ["drop tablespace", "DROP TABLESPACE ts", true], + ["reindex database", "REINDEX DATABASE postgres", true], + ["reindex system with options", "REINDEX (VERBOSE) SYSTEM postgres", true], + ["reindex table non-concurrent", "REINDEX TABLE public.widgets", false], + ["alter database", "ALTER DATABASE demo SET search_path = public", false], [ "lower-case create index concurrently", "create index concurrently widgets_id_idx on public.widgets(id)", From 57f3ebbd816cd6d154912d7ea4159a836f01e5a0 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:28:13 +0530 Subject: [PATCH 03/33] docs: align push side effects with transactional batches --- .../legacy/commands/db/push/SIDE_EFFECTS.md | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index fdf1627eb5..a1591c1476 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -27,14 +27,14 @@ before migrations unless `--skip-vault` is set. ## Database Mutations -| Statement | When | -| ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `RESET ALL` + migration statements + `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` | per pending migration (after confirmation); compatible statements use an implicit extended-protocol batch with one final `Sync`, while pipeline-incompatible statements run standalone — see Notes | -| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | -| `roles.sql` statements (no history row) | per `--include-roles` globals file (after confirmation); statements use an implicit extended-protocol batch with one final `Sync` | -| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets, migrations are applied, and `--skip-vault` is not set | -| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | -| `SET SESSION ROLE postgres` | stepped-down sessions only (`cli_login_*`/`supabase_admin`): after each top-level role-reverting statement, at the end of each migration/globals/seed file, and before the history insert and the `seed_files` upsert (CLI-2205, #6236) | +| Statement | When | +| ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RESET ALL` + migration statements + `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` | per pending migration (after confirmation); compatible statements use one explicitly transactional extended-protocol batch (`BEGIN` … `COMMIT`) with one final `Sync`, while pipeline-incompatible statements run standalone — see Notes | +| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | +| `roles.sql` statements (no history row) | per `--include-roles` globals file (after confirmation); statements use one explicitly transactional extended-protocol batch (`BEGIN` … `COMMIT`) with one final `Sync` | +| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets, migrations are applied, and `--skip-vault` is not set | +| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | +| `SET SESSION ROLE postgres` | stepped-down sessions only (`cli_login_*`/`supabase_admin`): after each top-level role-reverting statement, at the end of each migration/globals/seed file, and before the history insert and the `seed_files` upsert (CLI-2205, #6236) | ## API Routes @@ -118,7 +118,9 @@ stdout is payload-only. A single `result` object is emitted: load, including decrypted `encrypted:` values. `--skip-vault` leaves them unchanged and does not resolve or decrypt their configured values. - **Pipeline-incompatible statements**: `CREATE [UNIQUE] INDEX CONCURRENTLY`, - `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, and `CLUSTER` cannot run inside a + `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, + `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, and `REINDEX DATABASE`/`SYSTEM` + cannot run inside a transaction block (SQLSTATE 25001). The apply flushes (commits) the open batch, runs the statement standalone outside any transaction, then resumes batching; the history insert stays in the final batch so the migration is recorded only after every From ce99a402b3090bf543fbdd6935370784369590a6 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:29:33 +0530 Subject: [PATCH 04/33] test: assert batch errors via instanceof --- .../shared/legacy-db-connection.sql-pg.unit.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts index 371407da2c..ea00db5235 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts @@ -5,6 +5,7 @@ import type * as Pg from "pg"; import { describe, expect, it } from "vitest"; import { ErrorActionabilityId } from "../../shared/telemetry/error-actionability.ts"; +import { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import { LEGACY_SUGGEST_LOCAL_STACK } from "./legacy-connect-errors.ts"; import { legacyAcquireProbedPool, @@ -792,7 +793,7 @@ describe("legacyBatchFailureError", () => { true, ); - expect(error._tag).toBe("LegacyDbExecError"); + expect(error).toBeInstanceOf(LegacyDbExecError); expect(error).toMatchObject({ message: "failed to begin the batch transaction: " + @@ -805,7 +806,7 @@ describe("legacyBatchFailureError", () => { { completed: 0, outcome: "submitted", began: false }, true, ); - expect(lost._tag).toBe("LegacyDbExecError"); + expect(lost).toBeInstanceOf(LegacyDbExecError); expect(lost.message).toBe("Error: Connection terminated unexpectedly"); const terminated = legacyBatchFailureError( @@ -822,7 +823,7 @@ describe("legacyBatchFailureError", () => { { completed: 0, outcome: "submitted", began: false }, true, ); - expect(terminated._tag).toBe("LegacyDbExecError"); + expect(terminated).toBeInstanceOf(LegacyDbExecError); expect(terminated.message).toBe( "FATAL: terminating connection due to idle-session timeout (SQLSTATE 57P05)", ); @@ -832,7 +833,7 @@ describe("legacyBatchFailureError", () => { { completed: 0, outcome: "poisoned", began: false }, true, ); - expect(poisoned._tag).toBe("LegacyDbExecError"); + expect(poisoned).toBeInstanceOf(LegacyDbExecError); expect(poisoned.message).toBe( "ERROR: canceling statement due to statement timeout (SQLSTATE 57014)", ); From 32335b453929c22e3d1979fb5935b1cf51fc7de2 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:40:38 +0530 Subject: [PATCH 05/33] fix: route subscription ddl standalone --- apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md | 3 ++- apps/cli/src/legacy/shared/legacy-migration-apply.ts | 7 +++++-- .../src/legacy/shared/legacy-migration-apply.unit.test.ts | 3 +++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index a1591c1476..afdfd730cd 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -119,7 +119,8 @@ stdout is payload-only. A single `result` object is emitted: and does not resolve or decrypt their configured values. - **Pipeline-incompatible statements**: `CREATE [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, - `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, and `REINDEX DATABASE`/`SYSTEM` + `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`, and + `CREATE`/`DROP SUBSCRIPTION` cannot run inside a transaction block (SQLSTATE 25001). The apply flushes (commits) the open batch, runs the statement standalone outside any transaction, then resumes batching; the history diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index d9088d9085..5b2bae9fd4 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -67,6 +67,7 @@ const CLUSTER_PATTERN = /^CLUSTER(?:\s|$)/u; const DATABASE_DDL_PATTERN = /^(?:CREATE|DROP)\s+DATABASE(?:\s|$)/u; const TABLESPACE_DDL_PATTERN = /^(?:CREATE|DROP)\s+TABLESPACE(?:\s|$)/u; const REINDEX_DATABASE_PATTERN = /^REINDEX(?:\s+\([^)]*\))?\s+(?:DATABASE|SYSTEM)(?:\s|$)/u; +const SUBSCRIPTION_DDL_PATTERN = /^(?:CREATE|DROP)\s+SUBSCRIPTION(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; @@ -101,7 +102,8 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * Whether a migration statement cannot run inside a transaction block — `CREATE * [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, * `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, `CREATE`/`DROP DATABASE`, - * `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`. Such statements fail with + * `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`, + * `CREATE`/`DROP SUBSCRIPTION`. Such statements fail with * SQLSTATE 25001 inside the transaction * created by a migration batch, so `execMigrationBatch` runs them standalone. * Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156), @@ -119,7 +121,8 @@ export const legacyIsPipelineIncompatible = (sql: string): boolean => { CLUSTER_PATTERN.test(upper) || DATABASE_DDL_PATTERN.test(upper) || TABLESPACE_DDL_PATTERN.test(upper) || - REINDEX_DATABASE_PATTERN.test(upper) + REINDEX_DATABASE_PATTERN.test(upper) || + SUBSCRIPTION_DDL_PATTERN.test(upper) ); }; diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 53f89428e2..4c9ccc326d 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1124,6 +1124,9 @@ describe("legacyIsPipelineIncompatible", () => { ["reindex system with options", "REINDEX (VERBOSE) SYSTEM postgres", true], ["reindex table non-concurrent", "REINDEX TABLE public.widgets", false], ["alter database", "ALTER DATABASE demo SET search_path = public", false], + ["create subscription", "CREATE SUBSCRIPTION sub CONNECTION 'host=h' PUBLICATION pub", true], + ["drop subscription", "DROP SUBSCRIPTION IF EXISTS sub", true], + ["alter subscription", "ALTER SUBSCRIPTION sub DISABLE", false], [ "lower-case create index concurrently", "create index concurrently widgets_id_idx on public.widgets(id)", From 5afa6f749d7ea24fbb0019e7db316f1c9e8a28a7 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:45:23 +0530 Subject: [PATCH 06/33] fix: roll failed batches back while interruptible --- .../legacy-db-connection.sql-pg.layer.ts | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index 29ac1b0963..4d567e3329 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -1117,6 +1117,7 @@ const connect = ( const execBatch = (statements: ReadonlyArray) => { if (statements.length === 0) return Effect.void; let batchQuery: LegacyPgBatchQuery | undefined; + let rolledBack = false; // Spans the whole checkout: an unlistened 'error' kills the process (see // acquireRawClient) and pg-pool detaches its own handler while checked out. const onConnectionError = () => {}; @@ -1147,29 +1148,34 @@ const connect = ( return Effect.sync(() => { done = true; }); - }), + }).pipe( + // Roll a written batch's aborted transaction back while still + // interruptible; a rollback that fails or times out leaves the client + // to the discard below instead of returning it aborted (25P02). + Effect.tapError(() => + Effect.suspend(() => { + if (batchQuery?.outcome !== "submitted") return Effect.void; + return Effect.promise(() => + activeClient.query("ROLLBACK").then( + () => true, + () => false, + ), + ).pipe( + Effect.timeoutOption(1000), + Effect.map((result) => { + rolledBack = Option.getOrElse(result, () => false); + }), + ); + }), + ), + ), (activeClient, exit) => - Effect.suspend(() => { - const discard = legacyShouldDiscardBatchClient(batchQuery, exit); - const release = (broken: boolean) => - Effect.sync(() => { - activeClient.release(broken ? new Error("batch connection discarded") : undefined); - activeClient.removeListener("error", onConnectionError); - }); - if (discard || !Exit.isFailure(exit) || batchQuery?.outcome !== "submitted") { - return release(discard); - } - // Roll the aborted transaction back before the client is reused (25P02), - // bounded because this release step is uninterruptible. - return Effect.promise(() => - activeClient.query("ROLLBACK").then( - () => true, - () => false, - ), - ).pipe( - Effect.timeoutOption(1000), - Effect.flatMap((rolledBack) => release(!Option.getOrElse(rolledBack, () => false))), - ); + Effect.sync(() => { + const discard = + legacyShouldDiscardBatchClient(batchQuery, exit) || + (Exit.isFailure(exit) && batchQuery?.outcome === "submitted" && !rolledBack); + activeClient.release(discard ? new Error("batch connection discarded") : undefined); + activeClient.removeListener("error", onConnectionError); }), ); }; From 8335bfffb942558b1da3ebf1d5468cce539137c4 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:56:35 +0530 Subject: [PATCH 07/33] fix: run discard all standalone with role restore --- .../legacy/commands/db/push/SIDE_EFFECTS.md | 4 +-- .../legacy/shared/legacy-migration-apply.ts | 13 ++++++++-- .../legacy-migration-apply.unit.test.ts | 25 +++++++++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index afdfd730cd..38017a17b0 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -119,8 +119,8 @@ stdout is payload-only. A single `result` object is emitted: and does not resolve or decrypt their configured values. - **Pipeline-incompatible statements**: `CREATE [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, - `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`, and - `CREATE`/`DROP SUBSCRIPTION` + `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`, + `CREATE`/`DROP SUBSCRIPTION`, and `DISCARD ALL` cannot run inside a transaction block (SQLSTATE 25001). The apply flushes (commits) the open batch, runs the statement standalone outside any transaction, then resumes batching; the history diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 5b2bae9fd4..2a3cba0ee0 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -68,6 +68,7 @@ const DATABASE_DDL_PATTERN = /^(?:CREATE|DROP)\s+DATABASE(?:\s|$)/u; const TABLESPACE_DDL_PATTERN = /^(?:CREATE|DROP)\s+TABLESPACE(?:\s|$)/u; const REINDEX_DATABASE_PATTERN = /^REINDEX(?:\s+\([^)]*\))?\s+(?:DATABASE|SYSTEM)(?:\s|$)/u; const SUBSCRIPTION_DDL_PATTERN = /^(?:CREATE|DROP)\s+SUBSCRIPTION(?:\s|$)/u; +const DISCARD_ALL_PATTERN = /^DISCARD\s+ALL(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; @@ -103,7 +104,7 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, * `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, `CREATE`/`DROP DATABASE`, * `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`, - * `CREATE`/`DROP SUBSCRIPTION`. Such statements fail with + * `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`. Such statements fail with * SQLSTATE 25001 inside the transaction * created by a migration batch, so `execMigrationBatch` runs them standalone. * Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156), @@ -122,7 +123,8 @@ export const legacyIsPipelineIncompatible = (sql: string): boolean => { DATABASE_DDL_PATTERN.test(upper) || TABLESPACE_DDL_PATTERN.test(upper) || REINDEX_DATABASE_PATTERN.test(upper) || - SUBSCRIPTION_DDL_PATTERN.test(upper) + SUBSCRIPTION_DDL_PATTERN.test(upper) || + DISCARD_ALL_PATTERN.test(upper) ); }; @@ -787,6 +789,13 @@ const execMigrationBatch = ( yield* session .exec(statement) .pipe(Effect.mapError((cause) => legacyFormatExecBatchError(cause, index, statement))); + if (restoreRole !== undefined && legacyRevertsToLoginRole(statement)) { + yield* session + .exec(restoreRole) + .pipe( + Effect.mapError((cause) => legacyFormatExecBatchError(cause, index, restoreRole)), + ); + } executed += 1; } else { pending.push(statement); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 4c9ccc326d..b701541eac 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -854,6 +854,29 @@ describe("legacyApplyMigrationFile", () => { ); }); + it.effect("re-asserts postgres right after a standalone role-reverting statement", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_discard.sql"); + writeFileSync(file, "select 1;\nDISCARD ALL;\nselect 2;"); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + return run(session, file).pipe( + Effect.tap(() => + Effect.sync(() => { + const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); + expect(execs.slice(-2)).toEqual(["DISCARD ALL", "SET SESSION ROLE postgres"]); + const batches = calls.filter((call) => call.kind === "batch"); + expect(batches[0]?.statements?.map(({ sql }) => sql)).toEqual(["select 1"]); + expect(batches[1]?.statements?.map(({ sql }) => sql)).toEqual([ + "select 2", + "SET SESSION ROLE postgres", + expect.stringContaining("supabase_migrations.schema_migrations"), + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("reports a mid-file restore's own failure at its host statement", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); const file = join(dir, "20240101120000_fail.sql"); @@ -1127,6 +1150,8 @@ describe("legacyIsPipelineIncompatible", () => { ["create subscription", "CREATE SUBSCRIPTION sub CONNECTION 'host=h' PUBLICATION pub", true], ["drop subscription", "DROP SUBSCRIPTION IF EXISTS sub", true], ["alter subscription", "ALTER SUBSCRIPTION sub DISABLE", false], + ["discard all", "DISCARD ALL", true], + ["discard temp", "DISCARD TEMP", false], [ "lower-case create index concurrently", "create index concurrently widgets_id_idx on public.widgets(id)", From 7f55c997c2ecdf227a8743fe69d9ddce2eeeb0e1 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:15:10 +0530 Subject: [PATCH 08/33] fix: route reindex schema standalone --- apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md | 2 +- apps/cli/src/legacy/shared/legacy-migration-apply.ts | 4 ++-- .../cli/src/legacy/shared/legacy-migration-apply.unit.test.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index 38017a17b0..60d1d3ebf2 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -119,7 +119,7 @@ stdout is payload-only. A single `result` object is emitted: and does not resolve or decrypt their configured values. - **Pipeline-incompatible statements**: `CREATE [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, - `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`, + `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, `CREATE`/`DROP SUBSCRIPTION`, and `DISCARD ALL` cannot run inside a transaction block (SQLSTATE 25001). The apply flushes (commits) the open batch, runs diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 2a3cba0ee0..7585f0cbe8 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -66,7 +66,7 @@ const ALTER_SYSTEM_PATTERN = /^ALTER\s+SYSTEM(?:\s|$)/u; const CLUSTER_PATTERN = /^CLUSTER(?:\s|$)/u; const DATABASE_DDL_PATTERN = /^(?:CREATE|DROP)\s+DATABASE(?:\s|$)/u; const TABLESPACE_DDL_PATTERN = /^(?:CREATE|DROP)\s+TABLESPACE(?:\s|$)/u; -const REINDEX_DATABASE_PATTERN = /^REINDEX(?:\s+\([^)]*\))?\s+(?:DATABASE|SYSTEM)(?:\s|$)/u; +const REINDEX_DATABASE_PATTERN = /^REINDEX(?:\s+\([^)]*\))?\s+(?:DATABASE|SYSTEM|SCHEMA)(?:\s|$)/u; const SUBSCRIPTION_DDL_PATTERN = /^(?:CREATE|DROP)\s+SUBSCRIPTION(?:\s|$)/u; const DISCARD_ALL_PATTERN = /^DISCARD\s+ALL(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = @@ -103,7 +103,7 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * Whether a migration statement cannot run inside a transaction block — `CREATE * [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, * `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, `CREATE`/`DROP DATABASE`, - * `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`, + * `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, * `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`. Such statements fail with * SQLSTATE 25001 inside the transaction * created by a migration batch, so `execMigrationBatch` runs them standalone. diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index b701541eac..fbdd6a0f7a 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1145,6 +1145,7 @@ describe("legacyIsPipelineIncompatible", () => { ["drop tablespace", "DROP TABLESPACE ts", true], ["reindex database", "REINDEX DATABASE postgres", true], ["reindex system with options", "REINDEX (VERBOSE) SYSTEM postgres", true], + ["reindex schema", "REINDEX SCHEMA public", true], ["reindex table non-concurrent", "REINDEX TABLE public.widgets", false], ["alter database", "ALTER DATABASE demo SET search_path = public", false], ["create subscription", "CREATE SUBSCRIPTION sub CONNECTION 'host=h' PUBLICATION pub", true], From 35acc1be52d315f7b5a96a4179eac41da76f77f4 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:31:06 +0530 Subject: [PATCH 09/33] fix: route alter database set tablespace standalone --- apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md | 2 +- apps/cli/src/legacy/shared/legacy-migration-apply.ts | 7 +++++-- .../src/legacy/shared/legacy-migration-apply.unit.test.ts | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index 60d1d3ebf2..87c6636d6b 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -120,7 +120,7 @@ stdout is payload-only. A single `result` object is emitted: - **Pipeline-incompatible statements**: `CREATE [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, - `CREATE`/`DROP SUBSCRIPTION`, and `DISCARD ALL` + `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, and `ALTER DATABASE … SET TABLESPACE` cannot run inside a transaction block (SQLSTATE 25001). The apply flushes (commits) the open batch, runs the statement standalone outside any transaction, then resumes batching; the history diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 7585f0cbe8..fe79d964d0 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -69,6 +69,7 @@ const TABLESPACE_DDL_PATTERN = /^(?:CREATE|DROP)\s+TABLESPACE(?:\s|$)/u; const REINDEX_DATABASE_PATTERN = /^REINDEX(?:\s+\([^)]*\))?\s+(?:DATABASE|SYSTEM|SCHEMA)(?:\s|$)/u; const SUBSCRIPTION_DDL_PATTERN = /^(?:CREATE|DROP)\s+SUBSCRIPTION(?:\s|$)/u; const DISCARD_ALL_PATTERN = /^DISCARD\s+ALL(?:\s|$)/u; +const ALTER_DATABASE_TABLESPACE_PATTERN = /^ALTER\s+DATABASE\s+.*\sSET\s+TABLESPACE(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; @@ -104,7 +105,8 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, * `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, `CREATE`/`DROP DATABASE`, * `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, - * `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`. Such statements fail with + * `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, + * `ALTER DATABASE … SET TABLESPACE`. Such statements fail with * SQLSTATE 25001 inside the transaction * created by a migration batch, so `execMigrationBatch` runs them standalone. * Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156), @@ -124,7 +126,8 @@ export const legacyIsPipelineIncompatible = (sql: string): boolean => { TABLESPACE_DDL_PATTERN.test(upper) || REINDEX_DATABASE_PATTERN.test(upper) || SUBSCRIPTION_DDL_PATTERN.test(upper) || - DISCARD_ALL_PATTERN.test(upper) + DISCARD_ALL_PATTERN.test(upper) || + ALTER_DATABASE_TABLESPACE_PATTERN.test(upper) ); }; diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index fbdd6a0f7a..4ecfd21213 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1148,6 +1148,7 @@ describe("legacyIsPipelineIncompatible", () => { ["reindex schema", "REINDEX SCHEMA public", true], ["reindex table non-concurrent", "REINDEX TABLE public.widgets", false], ["alter database", "ALTER DATABASE demo SET search_path = public", false], + ["alter database set tablespace", "ALTER DATABASE demo SET TABLESPACE fast", true], ["create subscription", "CREATE SUBSCRIPTION sub CONNECTION 'host=h' PUBLICATION pub", true], ["drop subscription", "DROP SUBSCRIPTION IF EXISTS sub", true], ["alter subscription", "ALTER SUBSCRIPTION sub DISABLE", false], From 605f369e0f11cce60cff67f9face7811b4ed688c Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:31:25 +0530 Subject: [PATCH 10/33] fix: route subscription refresh forms standalone --- apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md | 3 ++- apps/cli/src/legacy/shared/legacy-migration-apply.ts | 8 ++++++-- .../src/legacy/shared/legacy-migration-apply.unit.test.ts | 3 +++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index 87c6636d6b..130154866f 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -120,7 +120,8 @@ stdout is payload-only. A single `result` object is emitted: - **Pipeline-incompatible statements**: `CREATE [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, - `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, and `ALTER DATABASE … SET TABLESPACE` + `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, `ALTER DATABASE … SET TABLESPACE`, and + `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION` cannot run inside a transaction block (SQLSTATE 25001). The apply flushes (commits) the open batch, runs the statement standalone outside any transaction, then resumes batching; the history diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index fe79d964d0..fa39103d02 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -70,6 +70,8 @@ const REINDEX_DATABASE_PATTERN = /^REINDEX(?:\s+\([^)]*\))?\s+(?:DATABASE|SYSTEM const SUBSCRIPTION_DDL_PATTERN = /^(?:CREATE|DROP)\s+SUBSCRIPTION(?:\s|$)/u; const DISCARD_ALL_PATTERN = /^DISCARD\s+ALL(?:\s|$)/u; const ALTER_DATABASE_TABLESPACE_PATTERN = /^ALTER\s+DATABASE\s+.*\sSET\s+TABLESPACE(?:\s|$)/u; +const ALTER_SUBSCRIPTION_REFRESH_PATTERN = + /^ALTER\s+SUBSCRIPTION\s+.*\s(?:REFRESH\s+PUBLICATION|(?:SET|ADD|DROP)\s+PUBLICATION)(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; @@ -106,7 +108,8 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, `CREATE`/`DROP DATABASE`, * `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, * `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, - * `ALTER DATABASE … SET TABLESPACE`. Such statements fail with + * `ALTER DATABASE … SET TABLESPACE`, + * `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`. Such statements fail with * SQLSTATE 25001 inside the transaction * created by a migration batch, so `execMigrationBatch` runs them standalone. * Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156), @@ -127,7 +130,8 @@ export const legacyIsPipelineIncompatible = (sql: string): boolean => { REINDEX_DATABASE_PATTERN.test(upper) || SUBSCRIPTION_DDL_PATTERN.test(upper) || DISCARD_ALL_PATTERN.test(upper) || - ALTER_DATABASE_TABLESPACE_PATTERN.test(upper) + ALTER_DATABASE_TABLESPACE_PATTERN.test(upper) || + ALTER_SUBSCRIPTION_REFRESH_PATTERN.test(upper) ); }; diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 4ecfd21213..3658948403 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1152,6 +1152,9 @@ describe("legacyIsPipelineIncompatible", () => { ["create subscription", "CREATE SUBSCRIPTION sub CONNECTION 'host=h' PUBLICATION pub", true], ["drop subscription", "DROP SUBSCRIPTION IF EXISTS sub", true], ["alter subscription", "ALTER SUBSCRIPTION sub DISABLE", false], + ["alter subscription refresh", "ALTER SUBSCRIPTION sub REFRESH PUBLICATION", true], + ["alter subscription set publication", "ALTER SUBSCRIPTION sub SET PUBLICATION p", true], + ["alter subscription set options", "ALTER SUBSCRIPTION sub SET (slot_name = 's')", false], ["discard all", "DISCARD ALL", true], ["discard temp", "DISCARD TEMP", false], [ From a6bd7327e7536357b7ed6f1ab63cdc7edcbed741 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:03:18 +0530 Subject: [PATCH 11/33] docs: align migration up standalone list --- apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md index 8a700a4c12..c52245038d 100644 --- a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md @@ -65,7 +65,11 @@ Same structured `applied` result delivered as an NDJSON `result` event. a non-linked target). - `--include-all` applies all migrations not found on the remote history table. - Pipeline-incompatible statements (`CREATE [UNIQUE] INDEX CONCURRENTLY`, - `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, `CLUSTER`) run standalone outside + `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, + `CLUSTER`, `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, + `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, + `ALTER DATABASE … SET TABLESPACE`, and + `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`) run standalone outside the migration's transaction batch — they fail with SQLSTATE 25001 inside one. The history insert stays in the final batch, so a mid-file failure leaves earlier, already-committed batches applied with **no history row**; a re-run replays the file From 9aece2f377f6179ce4691468d9adbd1a22366063 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:47:38 +0530 Subject: [PATCH 12/33] fix: allocate batch state per execution --- .../legacy-db-connection.sql-pg.layer.ts | 121 +++++++++--------- 1 file changed, 62 insertions(+), 59 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index 4d567e3329..bc8da901e7 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -1116,68 +1116,71 @@ const connect = ( const execBatch = (statements: ReadonlyArray) => { if (statements.length === 0) return Effect.void; - let batchQuery: LegacyPgBatchQuery | undefined; - let rolledBack = false; - // Spans the whole checkout: an unlistened 'error' kills the process (see - // acquireRawClient) and pg-pool detaches its own handler while checked out. - const onConnectionError = () => {}; - return Effect.acquireUseRelease( - Effect.interruptible(acquireBatchClient).pipe( - Effect.tap((activeClient) => - Effect.sync(() => activeClient.on("error", onConnectionError)), + // Suspended so each evaluation owns fresh batch/rollback state. + return Effect.suspend(() => { + let batchQuery: LegacyPgBatchQuery | undefined; + let rolledBack = false; + // Spans the whole checkout: an unlistened 'error' kills the process (see + // acquireRawClient) and pg-pool detaches its own handler while checked out. + const onConnectionError = () => {}; + return Effect.acquireUseRelease( + Effect.interruptible(acquireBatchClient).pipe( + Effect.tap((activeClient) => + Effect.sync(() => activeClient.on("error", onConnectionError)), + ), ), - ), - (activeClient) => - Effect.callback((resume) => { - let done = false; - const finish = (error: Error | undefined) => { - if (done) return; - done = true; - if (error === undefined) { - resume(Effect.void); - return; + (activeClient) => + Effect.callback((resume) => { + let done = false; + const finish = (error: Error | undefined) => { + if (done) return; + done = true; + if (error === undefined) { + resume(Effect.void); + return; + } + resume(Effect.fail(legacyBatchFailureError(error, batchQuery, options.isLocal))); + }; + batchQuery = new LegacyPgBatchQuery(statements, finish); + try { + activeClient.query(batchQuery); + } catch (error) { + finish(error instanceof Error ? error : new Error(String(error))); } - resume(Effect.fail(legacyBatchFailureError(error, batchQuery, options.isLocal))); - }; - batchQuery = new LegacyPgBatchQuery(statements, finish); - try { - activeClient.query(batchQuery); - } catch (error) { - finish(error instanceof Error ? error : new Error(String(error))); - } - return Effect.sync(() => { - done = true; - }); - }).pipe( - // Roll a written batch's aborted transaction back while still - // interruptible; a rollback that fails or times out leaves the client - // to the discard below instead of returning it aborted (25P02). - Effect.tapError(() => - Effect.suspend(() => { - if (batchQuery?.outcome !== "submitted") return Effect.void; - return Effect.promise(() => - activeClient.query("ROLLBACK").then( - () => true, - () => false, - ), - ).pipe( - Effect.timeoutOption(1000), - Effect.map((result) => { - rolledBack = Option.getOrElse(result, () => false); - }), - ); - }), + return Effect.sync(() => { + done = true; + }); + }).pipe( + // Roll a written batch's aborted transaction back while still + // interruptible; a rollback that fails or times out leaves the client + // to the discard below instead of returning it aborted (25P02). + Effect.tapError(() => + Effect.suspend(() => { + if (batchQuery?.outcome !== "submitted") return Effect.void; + return Effect.promise(() => + activeClient.query("ROLLBACK").then( + () => true, + () => false, + ), + ).pipe( + Effect.timeoutOption(1000), + Effect.map((result) => { + rolledBack = Option.getOrElse(result, () => false); + }), + ); + }), + ), ), - ), - (activeClient, exit) => - Effect.sync(() => { - const discard = - legacyShouldDiscardBatchClient(batchQuery, exit) || - (Exit.isFailure(exit) && batchQuery?.outcome === "submitted" && !rolledBack); - activeClient.release(discard ? new Error("batch connection discarded") : undefined); - activeClient.removeListener("error", onConnectionError); - }), - ); + (activeClient, exit) => + Effect.sync(() => { + const discard = + legacyShouldDiscardBatchClient(batchQuery, exit) || + (Exit.isFailure(exit) && batchQuery?.outcome === "submitted" && !rolledBack); + activeClient.release(discard ? new Error("batch connection discarded") : undefined); + activeClient.removeListener("error", onConnectionError); + }), + ); + }); }; const session: LegacyDbSession = { From 9cea8df10d731b93350c2cffe2b7f663c93cfcd4 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:47:46 +0530 Subject: [PATCH 13/33] fix: address batch review nits --- .../legacy/commands/db/push/SIDE_EFFECTS.md | 6 ++-- .../commands/migration/up/SIDE_EFFECTS.md | 6 ++-- .../legacy-db-connection.sql-pg.layer.ts | 35 +++++++++++++------ .../legacy-db-connection.sql-pg.unit.test.ts | 35 +++++++++++++------ .../legacy/shared/legacy-migration-apply.ts | 21 ++++++++--- .../legacy-migration-apply.unit.test.ts | 30 ++++++++++++++++ 6 files changed, 104 insertions(+), 29 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index 130154866f..fc7dc32396 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -121,12 +121,14 @@ stdout is payload-only. A single `result` object is emitted: `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, `ALTER DATABASE … SET TABLESPACE`, and - `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION` + `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, + `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE` cannot run inside a transaction block (SQLSTATE 25001). The apply flushes (commits) the open batch, runs the statement standalone outside any transaction, then resumes batching; the history insert stays in the final batch so the migration is recorded only after every - statement succeeds. Atomicity is therefore lost at each flush boundary: statements + statement succeeds. A failed batch's transaction is rolled back (bounded) before its + connection is reused; a rollback that fails or times out discards the connection. Atomicity is therefore lost at each flush boundary: statements committed in an earlier batch are **not** rolled back if a later statement fails, leaving the database partially migrated with **no history row** — a re-run replays the whole file from the top (which may then fail on already-applied statements). diff --git a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md index c52245038d..09fa020127 100644 --- a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md @@ -69,10 +69,12 @@ Same structured `applied` result delivered as an NDJSON `result` event. `CLUSTER`, `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, `ALTER DATABASE … SET TABLESPACE`, and - `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`) run standalone outside + `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, and + `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE`) run standalone outside the migration's transaction batch — they fail with SQLSTATE 25001 inside one. The history insert stays in the final batch, so a mid-file failure leaves earlier, already-committed batches applied with **no history row**; a re-run replays the file - from the top. Prefer idempotent forms (`… IF NOT EXISTS`) for such statements. + from the top. A failed batch's transaction is rolled back (bounded) before its + connection is reused. Prefer idempotent forms (`… IF NOT EXISTS`) for such statements. Intentional fix for supabase/cli#5139, adopted into TS in PR supabase/cli#5671 (landed on develop as `b48fad60`). diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index bc8da901e7..233e532102 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -109,6 +109,12 @@ function needsRoleStepDown(user: string): boolean { const LEGACY_TERMINAL_SQLSTATES = new Set(["28P01", "3D000", "42501"]); const LEGACY_TLS_GATED_SQLSTATE = "28000"; +// Class 08 (connection exception) plus the operator-intervention terminations that +// close the session; 57014 (query_canceled) stays a statement failure. +const LEGACY_SESSION_ENDING_SQLSTATES = new Set(["57P01", "57P02", "57P03", "57P05"]); +const legacyIsConnectionEndingSqlState = (code: string): boolean => + code.startsWith("08") || LEGACY_SESSION_ENDING_SQLSTATES.has(code); + /** * Whether a failed connection attempt should terminate the multi-host fallback * chain instead of falling through to the next host. Mirrors pgconn's @@ -242,11 +248,15 @@ export function legacyBatchFailureError( }); } const mapped = legacyToExecError(error); - // A lost connection (including a FATAL termination) is not a BEGIN failure. + // A lost connection (including a server-initiated termination) is not a BEGIN + // failure. Gated on SQLSTATE class, never the severity string, which arrives + // localized (e.g. "FEHLER"). + const server = legacyExtractPgServerError(error); const beganFailed = batch.outcome === "submitted" && batch.began === false && - legacyExtractPgServerError(error)?.severity === "ERROR"; + server !== undefined && + !legacyIsConnectionEndingSqlState(server.code); return new LegacyDbExecError({ message: beganFailed ? `failed to begin the batch transaction: ${mapped.message}` @@ -264,8 +274,8 @@ export function legacyBatchFailureError( * socket is already gone, so the next checkout would write into the same dead connection. * * A batch that WAS written keeps its client: a statement failure should not cost a redial and - * a fresh step-down on a single-connection pool. The keep is conditional on the release - * path's rollback — one that fails or times out discards the client after all. Recovering + * a fresh step-down on a single-connection pool. The keep is conditional on `rolledBack` — + * a failed submitted batch whose rollback failed or timed out is discarded. Recovering * from a socket that died after the write is additionally backstopped by pg-pool, which * drops a released client whose private `_queryable` flag is false — so that is the behavior * to re-check if a pg-pool bump ever breaks the recovery this layer's integration tests @@ -274,10 +284,14 @@ export function legacyBatchFailureError( export function legacyShouldDiscardBatchClient( batch: { readonly outcome: LegacyBatchOutcome } | undefined, exit: Exit.Exit, + rolledBack: boolean, ): boolean { return ( (batch !== undefined && batch.outcome !== "submitted") || - (Exit.isFailure(exit) && (Cause.hasInterrupts(exit.cause) || Cause.hasDies(exit.cause))) + (Exit.isFailure(exit) && + (Cause.hasInterrupts(exit.cause) || + Cause.hasDies(exit.cause) || + (batch?.outcome === "submitted" && !rolledBack))) ); } @@ -1173,11 +1187,12 @@ const connect = ( ), (activeClient, exit) => Effect.sync(() => { - const discard = - legacyShouldDiscardBatchClient(batchQuery, exit) || - (Exit.isFailure(exit) && batchQuery?.outcome === "submitted" && !rolledBack); - activeClient.release(discard ? new Error("batch connection discarded") : undefined); - activeClient.removeListener("error", onConnectionError); + const discard = legacyShouldDiscardBatchClient(batchQuery, exit, rolledBack); + try { + activeClient.release(discard ? new Error("batch connection discarded") : undefined); + } finally { + activeClient.removeListener("error", onConnectionError); + } }), ); }); diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts index ea00db5235..a6582eee93 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts @@ -870,27 +870,42 @@ describe("legacyBatchFailureError", () => { describe("legacyShouldDiscardBatchClient", () => { it("discards a client whose batch never reached the wire", () => { - expect(legacyShouldDiscardBatchClient({ outcome: "unsent" }, Exit.succeed(undefined))).toBe( - true, - ); + expect( + legacyShouldDiscardBatchClient({ outcome: "unsent" }, Exit.succeed(undefined), false), + ).toBe(true); }); - it("returns a client to the pool once its batch was written, error or not", () => { - expect(legacyShouldDiscardBatchClient({ outcome: "submitted" }, Exit.succeed(undefined))).toBe( - false, - ); + it("keeps a written batch's client on success or once its failure rolled back", () => { + expect( + legacyShouldDiscardBatchClient({ outcome: "submitted" }, Exit.succeed(undefined), false), + ).toBe(false); expect( legacyShouldDiscardBatchClient( { outcome: "submitted" }, Exit.fail(new Error("server said no")), + true, ), ).toBe(false); }); + it("discards a written batch's client when its failure was not rolled back", () => { + expect( + legacyShouldDiscardBatchClient( + { outcome: "submitted" }, + Exit.fail(new Error("server said no")), + false, + ), + ).toBe(true); + }); + it("discards a client whose batch was interrupted or died mid-flight", () => { - expect(legacyShouldDiscardBatchClient({ outcome: "submitted" }, Exit.interrupt(1))).toBe(true); - expect(legacyShouldDiscardBatchClient({ outcome: "submitted" }, Exit.die("boom"))).toBe(true); + expect(legacyShouldDiscardBatchClient({ outcome: "submitted" }, Exit.interrupt(1), true)).toBe( + true, + ); + expect(legacyShouldDiscardBatchClient({ outcome: "submitted" }, Exit.die("boom"), true)).toBe( + true, + ); // Interrupted before the batch was even constructed: no batch, still discard. - expect(legacyShouldDiscardBatchClient(undefined, Exit.interrupt(1))).toBe(true); + expect(legacyShouldDiscardBatchClient(undefined, Exit.interrupt(1), false)).toBe(true); }); }); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index fa39103d02..42ad17b3ac 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -69,9 +69,12 @@ const TABLESPACE_DDL_PATTERN = /^(?:CREATE|DROP)\s+TABLESPACE(?:\s|$)/u; const REINDEX_DATABASE_PATTERN = /^REINDEX(?:\s+\([^)]*\))?\s+(?:DATABASE|SYSTEM|SCHEMA)(?:\s|$)/u; const SUBSCRIPTION_DDL_PATTERN = /^(?:CREATE|DROP)\s+SUBSCRIPTION(?:\s|$)/u; const DISCARD_ALL_PATTERN = /^DISCARD\s+ALL(?:\s|$)/u; -const ALTER_DATABASE_TABLESPACE_PATTERN = /^ALTER\s+DATABASE\s+.*\sSET\s+TABLESPACE(?:\s|$)/u; +const ALTER_DATABASE_TABLESPACE_PATTERN = + /^ALTER\s+DATABASE\s+(?:"[^"]*"|\S+)\s+SET\s+TABLESPACE(?:\s|$)/u; const ALTER_SUBSCRIPTION_REFRESH_PATTERN = - /^ALTER\s+SUBSCRIPTION\s+.*\s(?:REFRESH\s+PUBLICATION|(?:SET|ADD|DROP)\s+PUBLICATION)(?:\s|$)/u; + /^ALTER\s+SUBSCRIPTION\s+(?:"[^"]*"|\S+)\s+(?:REFRESH\s+PUBLICATION|(?:SET|ADD|DROP)\s+PUBLICATION)(?:\s|$)/u; +const DETACH_PARTITION_PATTERN = + /^ALTER\s+TABLE\s+[\s\S]*\sDETACH\s+PARTITION\s+[\s\S]*\s(?:CONCURRENTLY|FINALIZE)(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; @@ -109,7 +112,8 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, * `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, * `ALTER DATABASE … SET TABLESPACE`, - * `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`. Such statements fail with + * `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, + * `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE`. Such statements fail with * SQLSTATE 25001 inside the transaction * created by a migration batch, so `execMigrationBatch` runs them standalone. * Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156), @@ -131,7 +135,8 @@ export const legacyIsPipelineIncompatible = (sql: string): boolean => { SUBSCRIPTION_DDL_PATTERN.test(upper) || DISCARD_ALL_PATTERN.test(upper) || ALTER_DATABASE_TABLESPACE_PATTERN.test(upper) || - ALTER_SUBSCRIPTION_REFRESH_PATTERN.test(upper) + ALTER_SUBSCRIPTION_REFRESH_PATTERN.test(upper) || + DETACH_PARTITION_PATTERN.test(upper) ); }; @@ -727,11 +732,14 @@ const execMigrationBatch = ( // (Go threads the same counter through `ExecBatch`). let pending: Array = []; let executed = 0; + let standaloneRestored = false; const flushBatch = (final: boolean) => Effect.gen(function* () { const recordVersion = final && version.length > 0; - const trailingRestore = final ? restoreRole : undefined; + // A standalone statement that just restored the role needs no trailing repeat. + const trailingRestore = + final && !(pending.length === 0 && standaloneRestored) ? restoreRole : undefined; if (pending.length === 0 && !recordVersion && trailingRestore === undefined) return; const batchStatements = pending; const operations: Array = []; @@ -796,16 +804,19 @@ const execMigrationBatch = ( yield* session .exec(statement) .pipe(Effect.mapError((cause) => legacyFormatExecBatchError(cause, index, statement))); + standaloneRestored = false; if (restoreRole !== undefined && legacyRevertsToLoginRole(statement)) { yield* session .exec(restoreRole) .pipe( Effect.mapError((cause) => legacyFormatExecBatchError(cause, index, restoreRole)), ); + standaloneRestored = true; } executed += 1; } else { pending.push(statement); + standaloneRestored = false; } } yield* flushBatch(true); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 3658948403..4d6b971d7a 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -877,6 +877,26 @@ describe("legacyApplyMigrationFile", () => { ); }); + it.effect("sends no trailing restore when the file ends on a restored standalone", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_discard_last.sql"); + writeFileSync(file, "select 1;\nDISCARD ALL;"); + const { session, calls } = fakeSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); + return run(session, file).pipe( + Effect.tap(() => + Effect.sync(() => { + const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); + expect(execs.slice(-2)).toEqual(["DISCARD ALL", "SET SESSION ROLE postgres"]); + const batches = calls.filter((call) => call.kind === "batch"); + expect(batches.at(-1)?.statements?.map(({ sql }) => sql)).toEqual([ + expect.stringContaining("supabase_migrations.schema_migrations"), + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); + it.effect("reports a mid-file restore's own failure at its host statement", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); const file = join(dir, "20240101120000_fail.sql"); @@ -1149,11 +1169,21 @@ describe("legacyIsPipelineIncompatible", () => { ["reindex table non-concurrent", "REINDEX TABLE public.widgets", false], ["alter database", "ALTER DATABASE demo SET search_path = public", false], ["alter database set tablespace", "ALTER DATABASE demo SET TABLESPACE fast", true], + ["alter database set tablespace multiline", "ALTER DATABASE demo\n SET TABLESPACE fast", true], + [ + "alter database with tablespace inside a literal", + "ALTER DATABASE demo SET application_name TO 'foo SET TABLESPACE bar'", + false, + ], + ["detach partition concurrently", "ALTER TABLE m DETACH PARTITION p CONCURRENTLY", true], + ["detach partition finalize", "ALTER TABLE m DETACH PARTITION p FINALIZE", true], + ["detach partition plain", "ALTER TABLE m DETACH PARTITION p", false], ["create subscription", "CREATE SUBSCRIPTION sub CONNECTION 'host=h' PUBLICATION pub", true], ["drop subscription", "DROP SUBSCRIPTION IF EXISTS sub", true], ["alter subscription", "ALTER SUBSCRIPTION sub DISABLE", false], ["alter subscription refresh", "ALTER SUBSCRIPTION sub REFRESH PUBLICATION", true], ["alter subscription set publication", "ALTER SUBSCRIPTION sub SET PUBLICATION p", true], + ["alter subscription refresh multiline", "ALTER SUBSCRIPTION sub\n REFRESH PUBLICATION", true], ["alter subscription set options", "ALTER SUBSCRIPTION sub SET (slot_name = 's')", false], ["discard all", "DISCARD ALL", true], ["discard temp", "DISCARD TEMP", false], From f2f254d5a45ce016fbdbfe9e01c44c953a6a3721 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:08:56 +0530 Subject: [PATCH 14/33] fix: tighten detach partition matching --- apps/cli/src/legacy/shared/legacy-migration-apply.ts | 2 +- .../legacy/shared/legacy-migration-apply.unit.test.ts | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 42ad17b3ac..26e605f26f 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -74,7 +74,7 @@ const ALTER_DATABASE_TABLESPACE_PATTERN = const ALTER_SUBSCRIPTION_REFRESH_PATTERN = /^ALTER\s+SUBSCRIPTION\s+(?:"[^"]*"|\S+)\s+(?:REFRESH\s+PUBLICATION|(?:SET|ADD|DROP)\s+PUBLICATION)(?:\s|$)/u; const DETACH_PARTITION_PATTERN = - /^ALTER\s+TABLE\s+[\s\S]*\sDETACH\s+PARTITION\s+[\s\S]*\s(?:CONCURRENTLY|FINALIZE)(?:\s|$)/u; + /^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?:"[^"]*"|\S+)\s+DETACH\s+PARTITION\s+(?:"[^"]*"|\S+)\s+(?:CONCURRENTLY|FINALIZE)(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 4d6b971d7a..bbac730ab2 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1178,6 +1178,16 @@ describe("legacyIsPipelineIncompatible", () => { ["detach partition concurrently", "ALTER TABLE m DETACH PARTITION p CONCURRENTLY", true], ["detach partition finalize", "ALTER TABLE m DETACH PARTITION p FINALIZE", true], ["detach partition plain", "ALTER TABLE m DETACH PARTITION p", false], + [ + "detach only inside a comment", + "ALTER TABLE m ADD COLUMN x int /* DETACH PARTITION p CONCURRENTLY */", + false, + ], + [ + "detach partition qualified concurrently", + "ALTER TABLE IF EXISTS ONLY s.m\n DETACH PARTITION p CONCURRENTLY", + true, + ], ["create subscription", "CREATE SUBSCRIPTION sub CONNECTION 'host=h' PUBLICATION pub", true], ["drop subscription", "DROP SUBSCRIPTION IF EXISTS sub", true], ["alter subscription", "ALTER SUBSCRIPTION sub DISABLE", false], From 0a7172a050bd578dab06ef869fb13bf63c79917d Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:09:48 +0530 Subject: [PATCH 15/33] fix: route all-in-tablespace moves standalone --- .../src/legacy/commands/db/push/SIDE_EFFECTS.md | 3 ++- .../legacy/commands/migration/up/SIDE_EFFECTS.md | 3 ++- .../src/legacy/shared/legacy-migration-apply.ts | 8 ++++++-- .../shared/legacy-migration-apply.unit.test.ts | 16 ++++++++++++++++ 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index fc7dc32396..f9ccf001d2 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -122,7 +122,8 @@ stdout is payload-only. A single `result` object is emitted: `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, `ALTER DATABASE … SET TABLESPACE`, and `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, - `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE` + `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE`, + `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE` cannot run inside a transaction block (SQLSTATE 25001). The apply flushes (commits) the open batch, runs the statement standalone outside any transaction, then resumes batching; the history diff --git a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md index 09fa020127..5860d5d134 100644 --- a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md @@ -70,7 +70,8 @@ Same structured `applied` result delivered as an NDJSON `result` event. `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, `ALTER DATABASE … SET TABLESPACE`, and `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, and - `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE`) run standalone outside + `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE`, and + `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE`) run standalone outside the migration's transaction batch — they fail with SQLSTATE 25001 inside one. The history insert stays in the final batch, so a mid-file failure leaves earlier, already-committed batches applied with **no history row**; a re-run replays the file diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 26e605f26f..d93834af8e 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -73,6 +73,8 @@ const ALTER_DATABASE_TABLESPACE_PATTERN = /^ALTER\s+DATABASE\s+(?:"[^"]*"|\S+)\s+SET\s+TABLESPACE(?:\s|$)/u; const ALTER_SUBSCRIPTION_REFRESH_PATTERN = /^ALTER\s+SUBSCRIPTION\s+(?:"[^"]*"|\S+)\s+(?:REFRESH\s+PUBLICATION|(?:SET|ADD|DROP)\s+PUBLICATION)(?:\s|$)/u; +const ALL_IN_TABLESPACE_PATTERN = + /^ALTER\s+(?:TABLE|INDEX|MATERIALIZED\s+VIEW)\s+ALL\s+IN\s+TABLESPACE(?:\s|$)/u; const DETACH_PARTITION_PATTERN = /^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?:"[^"]*"|\S+)\s+DETACH\s+PARTITION\s+(?:"[^"]*"|\S+)\s+(?:CONCURRENTLY|FINALIZE)(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = @@ -113,7 +115,8 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, * `ALTER DATABASE … SET TABLESPACE`, * `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, - * `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE`. Such statements fail with + * `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE`, + * `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE`. Such statements fail with * SQLSTATE 25001 inside the transaction * created by a migration batch, so `execMigrationBatch` runs them standalone. * Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156), @@ -136,7 +139,8 @@ export const legacyIsPipelineIncompatible = (sql: string): boolean => { DISCARD_ALL_PATTERN.test(upper) || ALTER_DATABASE_TABLESPACE_PATTERN.test(upper) || ALTER_SUBSCRIPTION_REFRESH_PATTERN.test(upper) || - DETACH_PARTITION_PATTERN.test(upper) + DETACH_PARTITION_PATTERN.test(upper) || + ALL_IN_TABLESPACE_PATTERN.test(upper) ); }; diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index bbac730ab2..d0c2ecd233 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1169,6 +1169,22 @@ describe("legacyIsPipelineIncompatible", () => { ["reindex table non-concurrent", "REINDEX TABLE public.widgets", false], ["alter database", "ALTER DATABASE demo SET search_path = public", false], ["alter database set tablespace", "ALTER DATABASE demo SET TABLESPACE fast", true], + [ + "alter table all in tablespace", + "ALTER TABLE ALL IN TABLESPACE old_ts SET TABLESPACE fast", + true, + ], + [ + "alter index all in tablespace", + "ALTER INDEX ALL IN TABLESPACE old_ts SET TABLESPACE fast", + true, + ], + [ + "alter materialized view all in tablespace", + "ALTER MATERIALIZED VIEW ALL IN TABLESPACE old_ts SET TABLESPACE fast", + true, + ], + ["alter table set tablespace single", "ALTER TABLE t SET TABLESPACE fast", false], ["alter database set tablespace multiline", "ALTER DATABASE demo\n SET TABLESPACE fast", true], [ "alter database with tablespace inside a literal", From 5b85ecc626d4e3ec2cd95effe023f529e51bcb68 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:21:08 +0530 Subject: [PATCH 16/33] fix: match qualified quoted detach names --- apps/cli/src/legacy/shared/legacy-migration-apply.ts | 2 +- .../src/legacy/shared/legacy-migration-apply.unit.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index d93834af8e..c516aad969 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -76,7 +76,7 @@ const ALTER_SUBSCRIPTION_REFRESH_PATTERN = const ALL_IN_TABLESPACE_PATTERN = /^ALTER\s+(?:TABLE|INDEX|MATERIALIZED\s+VIEW)\s+ALL\s+IN\s+TABLESPACE(?:\s|$)/u; const DETACH_PARTITION_PATTERN = - /^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?:"[^"]*"|\S+)\s+DETACH\s+PARTITION\s+(?:"[^"]*"|\S+)\s+(?:CONCURRENTLY|FINALIZE)(?:\s|$)/u; + /^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?:"[^"]*"|[^\s"])+\s+DETACH\s+PARTITION\s+(?:"[^"]*"|[^\s"])+\s+(?:CONCURRENTLY|FINALIZE)(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index d0c2ecd233..afe463e9b5 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1204,6 +1204,11 @@ describe("legacyIsPipelineIncompatible", () => { "ALTER TABLE IF EXISTS ONLY s.m\n DETACH PARTITION p CONCURRENTLY", true, ], + [ + "detach partition quoted qualified concurrently", + 'ALTER TABLE "tenant schema".events DETACH PARTITION "p 1" CONCURRENTLY', + true, + ], ["create subscription", "CREATE SUBSCRIPTION sub CONNECTION 'host=h' PUBLICATION pub", true], ["drop subscription", "DROP SUBSCRIPTION IF EXISTS sub", true], ["alter subscription", "ALTER SUBSCRIPTION sub DISABLE", false], From eae412ab52df2d809736a58bdf44a4bc22a3e2c3 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:33:22 +0530 Subject: [PATCH 17/33] fix: treat database-dropped as session-ending --- apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index 233e532102..456ce81b90 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -111,7 +111,7 @@ const LEGACY_TLS_GATED_SQLSTATE = "28000"; // Class 08 (connection exception) plus the operator-intervention terminations that // close the session; 57014 (query_canceled) stays a statement failure. -const LEGACY_SESSION_ENDING_SQLSTATES = new Set(["57P01", "57P02", "57P03", "57P05"]); +const LEGACY_SESSION_ENDING_SQLSTATES = new Set(["57P01", "57P02", "57P03", "57P04", "57P05"]); const legacyIsConnectionEndingSqlState = (code: string): boolean => code.startsWith("08") || LEGACY_SESSION_ENDING_SQLSTATES.has(code); From 1ad16ee57885fb3cc013f48be7d0d1b46f9a6458 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:35:02 +0530 Subject: [PATCH 18/33] fix: keep pipeline classifiers loose and uniform --- .../cli/src/legacy/shared/legacy-migration-apply.ts | 13 +++++++++---- .../shared/legacy-migration-apply.unit.test.ts | 13 +++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index c516aad969..9e2a1510e9 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -69,14 +69,13 @@ const TABLESPACE_DDL_PATTERN = /^(?:CREATE|DROP)\s+TABLESPACE(?:\s|$)/u; const REINDEX_DATABASE_PATTERN = /^REINDEX(?:\s+\([^)]*\))?\s+(?:DATABASE|SYSTEM|SCHEMA)(?:\s|$)/u; const SUBSCRIPTION_DDL_PATTERN = /^(?:CREATE|DROP)\s+SUBSCRIPTION(?:\s|$)/u; const DISCARD_ALL_PATTERN = /^DISCARD\s+ALL(?:\s|$)/u; -const ALTER_DATABASE_TABLESPACE_PATTERN = - /^ALTER\s+DATABASE\s+(?:"[^"]*"|\S+)\s+SET\s+TABLESPACE(?:\s|$)/u; +const ALTER_DATABASE_TABLESPACE_PATTERN = /^ALTER\s+DATABASE\s[\s\S]*\sSET\s+TABLESPACE(?:\s|$)/u; const ALTER_SUBSCRIPTION_REFRESH_PATTERN = - /^ALTER\s+SUBSCRIPTION\s+(?:"[^"]*"|\S+)\s+(?:REFRESH\s+PUBLICATION|(?:SET|ADD|DROP)\s+PUBLICATION)(?:\s|$)/u; + /^ALTER\s+SUBSCRIPTION\s[\s\S]*\s(?:REFRESH\s+PUBLICATION|(?:SET|ADD|DROP)\s+PUBLICATION)(?:\s|$)/u; const ALL_IN_TABLESPACE_PATTERN = /^ALTER\s+(?:TABLE|INDEX|MATERIALIZED\s+VIEW)\s+ALL\s+IN\s+TABLESPACE(?:\s|$)/u; const DETACH_PARTITION_PATTERN = - /^ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?:"[^"]*"|[^\s"])+\s+DETACH\s+PARTITION\s+(?:"[^"]*"|[^\s"])+\s+(?:CONCURRENTLY|FINALIZE)(?:\s|$)/u; + /^ALTER\s+TABLE\s[\s\S]*\sDETACH\s+PARTITION\s[\s\S]*\s(?:CONCURRENTLY|FINALIZE)(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; @@ -122,6 +121,12 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156), * extended with the remaining statement kinds PostgreSQL refuses inside the * explicit transaction the batch runs in since supabase/cli#6347. + * + * Deliberately loose, keyword-anchored matching — never identifier-aware: a match + * inside a comment or literal over-routes the statement standalone, which is always + * valid SQL placement and at worst adds a documented flush boundary, while an + * under-match is a hard SQLSTATE 25001 failure. Do not tighten these into + * identifier parsing. */ export const legacyIsPipelineIncompatible = (sql: string): boolean => { const upper = legacyTrimLeadingSqlComments(sql).toUpperCase(); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index afe463e9b5..2513f68124 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1187,17 +1187,17 @@ describe("legacyIsPipelineIncompatible", () => { ["alter table set tablespace single", "ALTER TABLE t SET TABLESPACE fast", false], ["alter database set tablespace multiline", "ALTER DATABASE demo\n SET TABLESPACE fast", true], [ - "alter database with tablespace inside a literal", + "alter database with tablespace inside a literal over-routes conservatively", "ALTER DATABASE demo SET application_name TO 'foo SET TABLESPACE bar'", - false, + true, ], ["detach partition concurrently", "ALTER TABLE m DETACH PARTITION p CONCURRENTLY", true], ["detach partition finalize", "ALTER TABLE m DETACH PARTITION p FINALIZE", true], ["detach partition plain", "ALTER TABLE m DETACH PARTITION p", false], [ - "detach only inside a comment", + "detach inside a comment over-routes conservatively", "ALTER TABLE m ADD COLUMN x int /* DETACH PARTITION p CONCURRENTLY */", - false, + true, ], [ "detach partition qualified concurrently", @@ -1209,6 +1209,11 @@ describe("legacyIsPipelineIncompatible", () => { 'ALTER TABLE "tenant schema".events DETACH PARTITION "p 1" CONCURRENTLY', true, ], + [ + "detach partition spaced qualification", + 'ALTER TABLE "tenant schema" . events DETACH PARTITION p CONCURRENTLY', + true, + ], ["create subscription", "CREATE SUBSCRIPTION sub CONNECTION 'host=h' PUBLICATION pub", true], ["drop subscription", "DROP SUBSCRIPTION IF EXISTS sub", true], ["alter subscription", "ALTER SUBSCRIPTION sub DISABLE", false], From b1b34058d5103ffdc051b19d7d6276646555bed2 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:35:55 +0530 Subject: [PATCH 19/33] fix: name commit failures in batch errors --- .../legacy-db-connection.sql-pg.layer.ts | 18 +++++--- .../legacy-db-connection.sql-pg.unit.test.ts | 43 +++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index 456ce81b90..09b3246807 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -235,6 +235,7 @@ export function legacyBatchFailureError( readonly completed: number; readonly outcome: LegacyBatchOutcome; readonly began?: boolean; + readonly atCommit?: boolean; } | undefined, isLocal: boolean, @@ -252,15 +253,15 @@ export function legacyBatchFailureError( // failure. Gated on SQLSTATE class, never the severity string, which arrives // localized (e.g. "FEHLER"). const server = legacyExtractPgServerError(error); - const beganFailed = - batch.outcome === "submitted" && - batch.began === false && - server !== undefined && - !legacyIsConnectionEndingSqlState(server.code); + const statementFailure = server !== undefined && !legacyIsConnectionEndingSqlState(server.code); + const beganFailed = batch.outcome === "submitted" && batch.began === false && statementFailure; + const commitFailed = batch.outcome === "submitted" && batch.atCommit === true && statementFailure; return new LegacyDbExecError({ message: beganFailed ? `failed to begin the batch transaction: ${mapped.message}` - : mapped.message, + : commitFailed + ? `failed to commit the batch transaction: ${mapped.message}` + : mapped.message, code: mapped.code, detail: mapped.detail, position: mapped.position, @@ -313,6 +314,11 @@ export class LegacyPgBatchQuery implements Pg.Submittable { outcome: LegacyBatchOutcome = "unsent"; began = false; + // An error arriving once every caller statement completed can only be COMMIT's. + get atCommit(): boolean { + return this.began && this.completed >= this.statements.length; + } + constructor( statements: ReadonlyArray, callback: (error: Error | undefined) => void, diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts index a6582eee93..37eab54ddc 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts @@ -839,6 +839,49 @@ describe("legacyBatchFailureError", () => { ); }); + it("names the transaction commit when a deferred failure lands on COMMIT", () => { + const deferred = new SqlError({ + reason: new SqlSyntaxError({ + cause: Object.assign(new Error("deferred constraint failed"), { + severity: "ERROR", + code: "23514", + }), + message: "Failed to execute statement", + operation: "execute", + }), + }); + const error = legacyBatchFailureError( + deferred, + { completed: 2, outcome: "submitted", began: true, atCommit: true }, + true, + ); + expect(error).toBeInstanceOf(LegacyDbExecError); + expect(error).toMatchObject({ + message: + "failed to commit the batch transaction: ERROR: deferred constraint failed (SQLSTATE 23514)", + statementIndex: 2, + }); + + const dropped = legacyBatchFailureError( + new SqlError({ + reason: new SqlSyntaxError({ + cause: Object.assign(new Error("terminating connection: database dropped"), { + severity: "FATAL", + code: "57P04", + }), + message: "Failed to execute statement", + operation: "execute", + }), + }), + { completed: 2, outcome: "submitted", began: true, atCommit: true }, + true, + ); + expect(dropped).toBeInstanceOf(LegacyDbExecError); + expect(dropped.message).toBe( + "FATAL: terminating connection: database dropped (SQLSTATE 57P04)", + ); + }); + it("keeps server-error mapping and the completed count for a statement failure", () => { const error = legacyBatchFailureError( new SqlError({ From 79cf21550e22d51d880f6c84a3aaf35ac2f0e30d Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:44:13 +0530 Subject: [PATCH 20/33] fix: polish batch classifier and rollback nits --- .../legacy/commands/migration/up/SIDE_EFFECTS.md | 4 ++-- .../shared/legacy-db-connection.service.ts | 7 ++++--- ...gacy-db-connection.sql-pg.integration.test.ts | 2 +- .../shared/legacy-db-connection.sql-pg.layer.ts | 16 ++++++++++------ .../src/legacy/shared/legacy-migration-apply.ts | 5 +++-- .../shared/legacy-migration-apply.unit.test.ts | 1 + 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md index 5860d5d134..0bae6fce25 100644 --- a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md @@ -68,8 +68,8 @@ Same structured `applied` result delivered as an NDJSON `result` event. `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, `CLUSTER`, `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, - `ALTER DATABASE … SET TABLESPACE`, and - `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, and + `ALTER DATABASE … SET TABLESPACE`, + `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE`, and `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE`) run standalone outside the migration's transaction batch — they fail with SQLSTATE 25001 inside one. The diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts index f42246ab90..76fa8efdad 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts @@ -110,9 +110,10 @@ export interface LegacyDbSession { /** * Run statements as one extended-protocol batch inside a single explicit * transaction, with a single final Sync — a bare pipeline is not a transaction - * block (supabase/cli#6347). On failure the batch is rolled back and - * {@link LegacyDbExecError.statementIndex} is the number of the caller's - * statements that completed before the error. + * block (supabase/cli#6347). On failure a bounded, best-effort rollback runs + * before the connection can be reused (a rollback that does not succeed + * discards the connection), and {@link LegacyDbExecError.statementIndex} is + * the number of the caller's statements that completed before the error. * * A batch runs on its own pooled connection, which the driver checks out per * call. Failing to acquire it, or losing it before any of the batch reaches the diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts index 0f479216db..2e1da411c1 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts @@ -704,7 +704,7 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { }), ); - it.live("absorbs a socket death during the release-path rollback instead of crashing", () => + it.live("absorbs a socket death during the error-path rollback instead of crashing", () => Effect.gen(function* () { const server = yield* Effect.promise(() => fakeBatchServer({ failExecuteAt: 3, destroyOnRollback: true }), diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index 09b3246807..e10aff0988 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -1177,12 +1177,16 @@ const connect = ( Effect.tapError(() => Effect.suspend(() => { if (batchQuery?.outcome !== "submitted") return Effect.void; - return Effect.promise(() => - activeClient.query("ROLLBACK").then( - () => true, - () => false, - ), - ).pipe( + return Effect.promise(() => { + try { + return activeClient.query("ROLLBACK").then( + () => true, + () => false, + ); + } catch { + return Promise.resolve(false); + } + }).pipe( Effect.timeoutOption(1000), Effect.map((result) => { rolledBack = Option.getOrElse(result, () => false); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 9e2a1510e9..f755eb4ff4 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -66,7 +66,7 @@ const ALTER_SYSTEM_PATTERN = /^ALTER\s+SYSTEM(?:\s|$)/u; const CLUSTER_PATTERN = /^CLUSTER(?:\s|$)/u; const DATABASE_DDL_PATTERN = /^(?:CREATE|DROP)\s+DATABASE(?:\s|$)/u; const TABLESPACE_DDL_PATTERN = /^(?:CREATE|DROP)\s+TABLESPACE(?:\s|$)/u; -const REINDEX_DATABASE_PATTERN = /^REINDEX(?:\s+\([^)]*\))?\s+(?:DATABASE|SYSTEM|SCHEMA)(?:\s|$)/u; +const REINDEX_DATABASE_PATTERN = /^REINDEX(?:\s*\([^)]*\))?\s+(?:DATABASE|SYSTEM|SCHEMA)(?:\s|$)/u; const SUBSCRIPTION_DDL_PATTERN = /^(?:CREATE|DROP)\s+SUBSCRIPTION(?:\s|$)/u; const DISCARD_ALL_PATTERN = /^DISCARD\s+ALL(?:\s|$)/u; const ALTER_DATABASE_TABLESPACE_PATTERN = /^ALTER\s+DATABASE\s[\s\S]*\sSET\s+TABLESPACE(?:\s|$)/u; @@ -115,7 +115,8 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * `ALTER DATABASE … SET TABLESPACE`, * `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, * `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE`, - * `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE`. Such statements fail with + * `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE`. Such statements (in + * their default forms) fail with * SQLSTATE 25001 inside the transaction * created by a migration batch, so `execMigrationBatch` runs them standalone. * Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156), diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 2513f68124..56d1e00147 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1165,6 +1165,7 @@ describe("legacyIsPipelineIncompatible", () => { ["drop tablespace", "DROP TABLESPACE ts", true], ["reindex database", "REINDEX DATABASE postgres", true], ["reindex system with options", "REINDEX (VERBOSE) SYSTEM postgres", true], + ["reindex database adjacent options", "REINDEX(VERBOSE) DATABASE postgres", true], ["reindex schema", "REINDEX SCHEMA public", true], ["reindex table non-concurrent", "REINDEX TABLE public.widgets", false], ["alter database", "ALTER DATABASE demo SET search_path = public", false], From d582b791dbd6e86be9cc35f94ee6bbc729edb0a4 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:54:09 +0530 Subject: [PATCH 21/33] fix: report commit failures without a statement tail --- .../legacy/shared/legacy-migration-apply.ts | 10 ++++++++ .../legacy-migration-apply.unit.test.ts | 23 +++++++++---------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index f755eb4ff4..0109611832 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -793,6 +793,16 @@ const execMigrationBatch = ( // `statementIndex` is set by every batch failure the driver raises; a // session that omits it can only have failed before the first statement. const raw = cause.statementIndex ?? 0; + // A failure past every operation is the wrapper's COMMIT (e.g. a + // deferred constraint): there is no statement to blame, so keep the + // driver's commit-labeled message without an `At statement` tail. + if (raw >= operations.length) { + const msg = [legacyErrorMessage(cause)]; + if (cause.detail !== undefined && cause.detail.length > 0) { + msg.push(cause.detail); + } + return formattedExecBatchFailure(msg.join("\n"), cause); + } const globalIndex = base + raw - (injectedBefore[raw] ?? injected); return legacyFormatExecBatchError( cause, diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 56d1e00147..ab50edba83 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -284,7 +284,7 @@ describe("legacyApplyMigrationFile", () => { ); }); - it.effect("defaults a deferred batch failure to the migration history statement", () => { + it.effect("reports a deferred commit failure without blaming a statement", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); const file = join(dir, "20240101120000_deferred.sql"); writeFileSync(file, "SELECT 1;"); @@ -293,8 +293,8 @@ describe("legacyApplyMigrationFile", () => { Effect.flip, Effect.tap((error) => Effect.sync(() => { - expect(error.message).toContain("At statement: 2"); - expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); + expect(error.message).not.toContain("At statement"); + expect(error.message).not.toContain("INSERT INTO supabase_migrations.schema_migrations"); }), ), Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), @@ -656,10 +656,9 @@ describe("legacyApplyMigrationFile", () => { ); }); - it.effect("keeps the deferred-failure index when a restore op is appended", () => { - // Mirrors "defaults a deferred batch failure to the migration history - // statement": the restore op between the statements and the insert must not - // shift the deferred (post-Sync) index either. + it.effect("reports a deferred commit failure cleanly when a restore op is appended", () => { + // The restore op between the statements and the insert must not resurrect a + // statement tail for a commit-phase failure. const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); const file = join(dir, "20240101120000_deferred.sql"); writeFileSync(file, "SELECT 1;"); @@ -671,8 +670,8 @@ describe("legacyApplyMigrationFile", () => { Effect.flip, Effect.tap((error) => Effect.sync(() => { - expect(error.message).toContain("At statement: 2"); - expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); + expect(error.message).not.toContain("At statement"); + expect(error.message).not.toContain("INSERT INTO supabase_migrations.schema_migrations"); rmSync(dir, { recursive: true, force: true }); }), ), @@ -808,7 +807,7 @@ describe("legacyApplyMigrationFile", () => { ); }); - it.effect("keeps the deferred-failure index when mid-file restores were injected", () => { + it.effect("reports a deferred commit failure cleanly with mid-file restores injected", () => { // The `injectedBefore[raw] ?? injected` fallback only matters when the // deferred (post-Sync) index lands past the ops array AND injections exist. const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); @@ -822,8 +821,8 @@ describe("legacyApplyMigrationFile", () => { Effect.flip, Effect.tap((error) => Effect.sync(() => { - expect(error.message).toContain("At statement: 4"); - expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); + expect(error.message).not.toContain("At statement"); + expect(error.message).not.toContain("INSERT INTO supabase_migrations.schema_migrations"); rmSync(dir, { recursive: true, force: true }); }), ), From 3e9d1e59328bd393e2f2e1cd3d6587ed385e3f2c Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:11:24 +0530 Subject: [PATCH 22/33] fix: omit statement context for begin failures --- .../shared/legacy-db-connection.errors.ts | 5 +++ .../legacy-db-connection.sql-pg.layer.ts | 2 ++ .../legacy-db-connection.sql-pg.unit.test.ts | 2 ++ .../legacy/shared/legacy-migration-apply.ts | 8 ++--- .../legacy-migration-apply.unit.test.ts | 33 ++++++++++++++++++- 5 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts index 99fda4cef2..b4e927546c 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts @@ -36,6 +36,11 @@ export class LegacyDbExecError extends Data.TaggedError("LegacyDbExecError")<{ * the batch length for a deferred Sync failure. Absent for `exec`/`query`. */ readonly statementIndex?: number; + /** + * Which CLI-injected transaction wrapper failed, when the failure was BEGIN's + * or COMMIT's rather than a caller statement's. Absent otherwise. + */ + readonly transactionPhase?: "begin" | "commit"; /** * Postgres SQLSTATE (e.g. `42P01` undefined_table), extracted from the driver * error's `cause` chain when present. Lets callers match Go's error-code checks diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index e10aff0988..58260581c5 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -266,6 +266,8 @@ export function legacyBatchFailureError( detail: mapped.detail, position: mapped.position, statementIndex: batch.completed, + ...(beganFailed ? { transactionPhase: "begin" as const } : {}), + ...(commitFailed ? { transactionPhase: "commit" as const } : {}), }); } diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts index 37eab54ddc..a9c1f31764 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts @@ -799,6 +799,7 @@ describe("legacyBatchFailureError", () => { "failed to begin the batch transaction: " + "ERROR: canceling statement due to statement timeout (SQLSTATE 57014)", statementIndex: 0, + transactionPhase: "begin", }); const lost = legacyBatchFailureError( @@ -860,6 +861,7 @@ describe("legacyBatchFailureError", () => { message: "failed to commit the batch transaction: ERROR: deferred constraint failed (SQLSTATE 23514)", statementIndex: 2, + transactionPhase: "commit", }); const dropped = legacyBatchFailureError( diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 0109611832..e129e52376 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -793,10 +793,10 @@ const execMigrationBatch = ( // `statementIndex` is set by every batch failure the driver raises; a // session that omits it can only have failed before the first statement. const raw = cause.statementIndex ?? 0; - // A failure past every operation is the wrapper's COMMIT (e.g. a - // deferred constraint): there is no statement to blame, so keep the - // driver's commit-labeled message without an `At statement` tail. - if (raw >= operations.length) { + // A wrapper (BEGIN/COMMIT) failure, or one past every operation + // (e.g. a deferred constraint), blames no statement: keep the + // driver's phase-labeled message without an `At statement` tail. + if (cause.transactionPhase !== undefined || raw >= operations.length) { const msg = [legacyErrorMessage(cause)]; if (cause.detail !== undefined && cause.detail.length > 0) { msg.push(cause.detail); diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index ab50edba83..c163dcc9c3 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -31,6 +31,7 @@ class FakeExecError extends Data.TaggedError("LegacyDbExecError")<{ readonly detail?: string; readonly position?: number; readonly statementIndex?: number; + readonly transactionPhase?: "begin" | "commit"; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.dbFinding; @@ -41,7 +42,13 @@ function fakeSession( opts: { failOn?: string; failAfterBatch?: boolean; - failWith?: { message: string; code?: string; detail?: string; position?: number }; + failWith?: { + message: string; + code?: string; + detail?: string; + position?: number; + transactionPhase?: "begin" | "commit"; + }; restoreRoleSql?: string; batchConnectionLost?: string; } = {}, @@ -284,6 +291,30 @@ describe("legacyApplyMigrationFile", () => { ); }); + it.effect("reports a begin failure without blaming the first statement", () => { + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_begin.sql"); + writeFileSync(file, "SELECT 1;"); + const { session } = fakeSession({ + failOn: "SELECT 1", + failWith: { + message: "failed to begin the batch transaction: ERROR: canceling statement", + transactionPhase: "begin", + }, + }); + return run(session, file).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("failed to begin the batch transaction"); + expect(error.message).not.toContain("At statement"); + expect(error.message).not.toContain("SELECT 1"); + }), + ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + }); + it.effect("reports a deferred commit failure without blaming a statement", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); const file = join(dir, "20240101120000_deferred.sql"); From c838da3fae3b1a9982fcc82128531368d81fbd2b Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:15:01 +0530 Subject: [PATCH 23/33] fix: match parenthesized reindex concurrently --- apps/cli/src/legacy/shared/legacy-migration-apply.ts | 2 ++ .../src/legacy/shared/legacy-migration-apply.unit.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index e129e52376..24c5a8193f 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -61,6 +61,7 @@ const BOM_CODE_POINT = 0xfeff; const CREATE_INDEX_CONCURRENTLY_PATTERN = /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY(?:\s|$)/u; const DROP_INDEX_CONCURRENTLY_PATTERN = /^DROP\s+INDEX\s+CONCURRENTLY(?:\s|$)/u; const REINDEX_CONCURRENTLY_PATTERN = /^REINDEX(?:\s|\().*\sCONCURRENTLY(?:\s|$)/u; +const REINDEX_OPTION_CONCURRENTLY_PATTERN = /^REINDEX\s*\([^)]*\bCONCURRENTLY\b[^)]*\)/u; const VACUUM_PATTERN = /^VACUUM(?:\s|\(|$)/u; const ALTER_SYSTEM_PATTERN = /^ALTER\s+SYSTEM(?:\s|$)/u; const CLUSTER_PATTERN = /^CLUSTER(?:\s|$)/u; @@ -135,6 +136,7 @@ export const legacyIsPipelineIncompatible = (sql: string): boolean => { CREATE_INDEX_CONCURRENTLY_PATTERN.test(upper) || DROP_INDEX_CONCURRENTLY_PATTERN.test(upper) || REINDEX_CONCURRENTLY_PATTERN.test(upper) || + REINDEX_OPTION_CONCURRENTLY_PATTERN.test(upper) || VACUUM_PATTERN.test(upper) || ALTER_SYSTEM_PATTERN.test(upper) || CLUSTER_PATTERN.test(upper) || diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index c163dcc9c3..631c74f311 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1196,6 +1196,14 @@ describe("legacyIsPipelineIncompatible", () => { ["reindex database", "REINDEX DATABASE postgres", true], ["reindex system with options", "REINDEX (VERBOSE) SYSTEM postgres", true], ["reindex database adjacent options", "REINDEX(VERBOSE) DATABASE postgres", true], + ["reindex concurrently as option", "REINDEX (CONCURRENTLY) INDEX widgets_id_idx", true], + ["reindex mixed option list", "REINDEX (VERBOSE, CONCURRENTLY) TABLE public.widgets", true], + [ + "reindex concurrently false over-routes conservatively", + "REINDEX (CONCURRENTLY FALSE) INDEX widgets_id_idx", + true, + ], + ["reindex verbose option only", "REINDEX (VERBOSE) INDEX widgets_id_idx", false], ["reindex schema", "REINDEX SCHEMA public", true], ["reindex table non-concurrent", "REINDEX TABLE public.widgets", false], ["alter database", "ALTER DATABASE demo SET search_path = public", false], From 02db92251904a5ddc160e8c61171cfa24f19afbd Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:34:07 +0530 Subject: [PATCH 24/33] fix: keep detach partition finalize batched --- apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md | 2 +- apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md | 2 +- apps/cli/src/legacy/shared/legacy-migration-apply.ts | 4 ++-- .../cli/src/legacy/shared/legacy-migration-apply.unit.test.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index f9ccf001d2..d1c9dd0f73 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -122,7 +122,7 @@ stdout is payload-only. A single `result` object is emitted: `CREATE`/`DROP DATABASE`, `CREATE`/`DROP TABLESPACE`, `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, `ALTER DATABASE … SET TABLESPACE`, and `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, - `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE`, + `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`, `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE` cannot run inside a transaction block (SQLSTATE 25001). The apply flushes (commits) the open batch, runs diff --git a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md index 0bae6fce25..7b6a211823 100644 --- a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md @@ -70,7 +70,7 @@ Same structured `applied` result delivered as an NDJSON `result` event. `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, `ALTER DATABASE … SET TABLESPACE`, `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, - `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE`, and + `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`, and `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE`) run standalone outside the migration's transaction batch — they fail with SQLSTATE 25001 inside one. The history insert stays in the final batch, so a mid-file failure leaves earlier, diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 24c5a8193f..f5cdf01521 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -76,7 +76,7 @@ const ALTER_SUBSCRIPTION_REFRESH_PATTERN = const ALL_IN_TABLESPACE_PATTERN = /^ALTER\s+(?:TABLE|INDEX|MATERIALIZED\s+VIEW)\s+ALL\s+IN\s+TABLESPACE(?:\s|$)/u; const DETACH_PARTITION_PATTERN = - /^ALTER\s+TABLE\s[\s\S]*\sDETACH\s+PARTITION\s[\s\S]*\s(?:CONCURRENTLY|FINALIZE)(?:\s|$)/u; + /^ALTER\s+TABLE\s[\s\S]*\sDETACH\s+PARTITION\s[\s\S]*\sCONCURRENTLY(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; @@ -115,7 +115,7 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, * `ALTER DATABASE … SET TABLESPACE`, * `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, - * `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`/`FINALIZE`, + * `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`, * `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE`. Such statements (in * their default forms) fail with * SQLSTATE 25001 inside the transaction diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 631c74f311..0acf013ed5 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1231,7 +1231,7 @@ describe("legacyIsPipelineIncompatible", () => { true, ], ["detach partition concurrently", "ALTER TABLE m DETACH PARTITION p CONCURRENTLY", true], - ["detach partition finalize", "ALTER TABLE m DETACH PARTITION p FINALIZE", true], + ["detach partition finalize stays batched", "ALTER TABLE m DETACH PARTITION p FINALIZE", false], ["detach partition plain", "ALTER TABLE m DETACH PARTITION p", false], [ "detach inside a comment over-routes conservatively", From 7ddeddc198fce65d980c0ff43554646ccd147561 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:34:07 +0530 Subject: [PATCH 25/33] docs: note wrapper failures in reset contract --- apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md index 5b2dfb6b5e..54ea002752 100644 --- a/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/reset/SIDE_EFFECTS.md @@ -152,7 +152,10 @@ reported as a lost connection (with the driver's own reason and, locally, the hi stack) rather than against a statement that never ran. Once any part of the batch has been written, and for the pipeline-incompatible statements the same loop runs on their own (`CREATE INDEX CONCURRENTLY`, `VACUUM`, ...), a failure still reports as `At statement: N` with the statement -echoed, because those may genuinely have reached the server. +echoed, because those may genuinely have reached the server. The one exception is a failure of the +batch's own transaction wrapper — a rejected `BEGIN`, or a deferred constraint surfacing at +`COMMIT` — which reports the phase-labeled driver message with no statement context, since no +caller statement is to blame. ## Exit Codes From abf4aff053519a3e62c87f0a122f2baa2ff88f57 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:46:44 +0530 Subject: [PATCH 26/33] fix: type transaction phase without assertions --- .../legacy/shared/legacy-db-connection.sql-pg.layer.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index 58260581c5..19149b1ac2 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -256,6 +256,11 @@ export function legacyBatchFailureError( const statementFailure = server !== undefined && !legacyIsConnectionEndingSqlState(server.code); const beganFailed = batch.outcome === "submitted" && batch.began === false && statementFailure; const commitFailed = batch.outcome === "submitted" && batch.atCommit === true && statementFailure; + const transactionPhase: "begin" | "commit" | undefined = beganFailed + ? "begin" + : commitFailed + ? "commit" + : undefined; return new LegacyDbExecError({ message: beganFailed ? `failed to begin the batch transaction: ${mapped.message}` @@ -266,8 +271,7 @@ export function legacyBatchFailureError( detail: mapped.detail, position: mapped.position, statementIndex: batch.completed, - ...(beganFailed ? { transactionPhase: "begin" as const } : {}), - ...(commitFailed ? { transactionPhase: "commit" as const } : {}), + ...(transactionPhase !== undefined ? { transactionPhase } : {}), }); } From f83f6723ec4f6eae1a8a7bb51663813b82d2d6d7 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:47:04 +0530 Subject: [PATCH 27/33] docs: note roles batch splits in push contract --- .../src/legacy/commands/db/push/SIDE_EFFECTS.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index d1c9dd0f73..962112a0b2 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -27,14 +27,14 @@ before migrations unless `--skip-vault` is set. ## Database Mutations -| Statement | When | -| ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `RESET ALL` + migration statements + `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` | per pending migration (after confirmation); compatible statements use one explicitly transactional extended-protocol batch (`BEGIN` … `COMMIT`) with one final `Sync`, while pipeline-incompatible statements run standalone — see Notes | -| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | -| `roles.sql` statements (no history row) | per `--include-roles` globals file (after confirmation); statements use one explicitly transactional extended-protocol batch (`BEGIN` … `COMMIT`) with one final `Sync` | -| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets, migrations are applied, and `--skip-vault` is not set | -| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | -| `SET SESSION ROLE postgres` | stepped-down sessions only (`cli_login_*`/`supabase_admin`): after each top-level role-reverting statement, at the end of each migration/globals/seed file, and before the history insert and the `seed_files` upsert (CLI-2205, #6236) | +| Statement | When | +| ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `RESET ALL` + migration statements + `INSERT INTO supabase_migrations.schema_migrations(version, name, statements)` | per pending migration (after confirmation); compatible statements use one explicitly transactional extended-protocol batch (`BEGIN` … `COMMIT`) with one final `Sync`, while pipeline-incompatible statements run standalone — see Notes | +| `CREATE SCHEMA/TABLE … supabase_migrations.schema_migrations`, `ALTER TABLE … ADD COLUMN …` | once before applying migrations (idempotent) | +| `roles.sql` statements (no history row) | per `--include-roles` globals file (after confirmation); compatible statements use one explicitly transactional extended-protocol batch (`BEGIN` … `COMMIT`) with one final `Sync`, with the same standalone/sequential exceptions as migrations — see Notes | +| `SELECT id, name FROM vault.secrets …`, `SELECT vault.update_secret(...)`, `SELECT vault.create_secret(...)` | when `[db.vault]` has syncable secrets, migrations are applied, and `--skip-vault` is not set | +| `CREATE TABLE … supabase_migrations.seed_files`, seed statements, `INSERT … seed_files(path, hash) … ON CONFLICT …` | per pending seed file with `--include-seed` (after confirmation); a dirty seed only refreshes the hash | +| `SET SESSION ROLE postgres` | stepped-down sessions only (`cli_login_*`/`supabase_admin`): after each top-level role-reverting statement, at the end of each migration/globals/seed file, and before the history insert and the `seed_files` upsert (CLI-2205, #6236) | ## API Routes From d3beb8dfc86be8c488a3a59f8ab4cc0ad5678809 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Mon, 31 Aug 2026 02:50:17 +0530 Subject: [PATCH 28/33] fix: mark wrapper phase on connection loss --- .../shared/legacy-db-connection.errors.ts | 5 ++- ...y-db-connection.sql-pg.integration.test.ts | 37 +++++++++++++++++++ .../legacy-db-connection.sql-pg.layer.ts | 29 +++++++++------ .../legacy-db-connection.sql-pg.unit.test.ts | 24 ++++++++---- .../legacy-migration-apply.unit.test.ts | 27 ++++++++++++++ 5 files changed, 101 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts index b4e927546c..72b683d183 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts @@ -37,8 +37,9 @@ export class LegacyDbExecError extends Data.TaggedError("LegacyDbExecError")<{ */ readonly statementIndex?: number; /** - * Which CLI-injected transaction wrapper failed, when the failure was BEGIN's - * or COMMIT's rather than a caller statement's. Absent otherwise. + * Which CLI-injected transaction wrapper was in flight when the batch failed — + * whether the server rejected BEGIN/COMMIT or the connection was lost while one + * was pending — rather than a caller statement. Absent otherwise. */ readonly transactionPhase?: "begin" | "commit"; /** diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts index 2e1da411c1..3ca1e2fd46 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts @@ -182,6 +182,8 @@ const fakeBatchServer = ( readonly destroyOnRollback?: boolean; /** Drop the connection on the first Sync, so a batch dies mid-flight. */ readonly destroyOnFirstSync?: boolean; + /** Drop the connection at the first Execute (BEGIN's), before anything completes. */ + readonly destroyOnFirstExecute?: boolean; } = {}, ): Promise<{ readonly port: number; @@ -198,6 +200,7 @@ const fakeBatchServer = ( syncs: 0, }; const sockets: Array = []; + let destroyedOnExecute = false; const server = net.createServer((socket) => { sockets.push(socket); let sawStartup = false; @@ -265,6 +268,11 @@ const fakeBatchServer = ( } else if (type === "D") { if (!failed) socket.write(NO_DATA); } else if (type === "E") { + if (options.destroyOnFirstExecute === true && !destroyedOnExecute) { + destroyedOnExecute = true; + socket.destroy(); + return; + } if (!failed) { if (activeIndex === options.failExecuteAt) { failed = true; @@ -768,6 +776,35 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { ); expect(error._tag).toBe("LegacyDbExecError"); expect(asBatchExecError(error).message).toContain("Connection terminated unexpectedly"); + // This server acks every statement and dies at Sync, so the loss lands + // on COMMIT — marked so the formatter never blames a caller statement. + expect(asBatchExecError(error).transactionPhase).toBe("commit"); + yield* session.execBatch([{ sql: "SELECT 3" }]); + }), + ); + }), + ); + + it.live("marks the begin phase when the connection drops before BEGIN completes", () => + // The loss arrives while BEGIN is still in flight, so no caller statement ran: + // the phase marker keeps formatters from rendering `At statement: 0` for it. + Effect.gen(function* () { + const server = yield* Effect.promise(() => fakeBatchServer({ destroyOnFirstExecute: true })); + yield* runWithBatchServer(server, (session) => + Effect.gen(function* () { + const error = yield* session.execBatch([{ sql: "SELECT 1" }, { sql: "SELECT 2" }]).pipe( + Effect.flip, + Effect.timeoutOrElse({ + duration: Duration.seconds(10), + orElse: () => Effect.die("execBatch never settled after the connection died"), + }), + ); + expect(error._tag).toBe("LegacyDbExecError"); + expect(asBatchExecError(error).message).toContain("Connection terminated unexpectedly"); + expect(asBatchExecError(error)).toMatchObject({ + statementIndex: 0, + transactionPhase: "begin", + }); yield* session.execBatch([{ sql: "SELECT 3" }]); }), ); diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index 19149b1ac2..7c8f37c415 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -249,24 +249,29 @@ export function legacyBatchFailureError( }); } const mapped = legacyToExecError(error); - // A lost connection (including a server-initiated termination) is not a BEGIN - // failure. Gated on SQLSTATE class, never the severity string, which arrives - // localized (e.g. "FEHLER"). + // The phase marker and the relabel are separate: whenever BEGIN or COMMIT was + // the statement in flight, none of the caller's statements failed at + // `statementIndex`, so the phase is always recorded and formatters must not + // blame one. The message is only relabeled when the server rejected the + // wrapper itself — a lost connection (including a server-initiated + // termination) keeps its own reason. Gated on SQLSTATE class, never the + // severity string, which arrives localized (e.g. "FEHLER"). const server = legacyExtractPgServerError(error); const statementFailure = server !== undefined && !legacyIsConnectionEndingSqlState(server.code); - const beganFailed = batch.outcome === "submitted" && batch.began === false && statementFailure; - const commitFailed = batch.outcome === "submitted" && batch.atCommit === true && statementFailure; - const transactionPhase: "begin" | "commit" | undefined = beganFailed + const atBegin = batch.outcome === "submitted" && batch.began === false; + const atCommit = batch.outcome === "submitted" && batch.atCommit === true; + const transactionPhase: "begin" | "commit" | undefined = atBegin ? "begin" - : commitFailed + : atCommit ? "commit" : undefined; return new LegacyDbExecError({ - message: beganFailed - ? `failed to begin the batch transaction: ${mapped.message}` - : commitFailed - ? `failed to commit the batch transaction: ${mapped.message}` - : mapped.message, + message: + atBegin && statementFailure + ? `failed to begin the batch transaction: ${mapped.message}` + : atCommit && statementFailure + ? `failed to commit the batch transaction: ${mapped.message}` + : mapped.message, code: mapped.code, detail: mapped.detail, position: mapped.position, diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts index a9c1f31764..495b1bde1c 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.unit.test.ts @@ -808,7 +808,13 @@ describe("legacyBatchFailureError", () => { true, ); expect(lost).toBeInstanceOf(LegacyDbExecError); - expect(lost.message).toBe("Error: Connection terminated unexpectedly"); + // A connection lost at BEGIN keeps its own reason, but still marks the phase: + // no caller statement ran, so nothing downstream may render `At statement: 0`. + expect(lost).toMatchObject({ + message: "Error: Connection terminated unexpectedly", + statementIndex: 0, + transactionPhase: "begin", + }); const terminated = legacyBatchFailureError( new SqlError({ @@ -825,9 +831,10 @@ describe("legacyBatchFailureError", () => { true, ); expect(terminated).toBeInstanceOf(LegacyDbExecError); - expect(terminated.message).toBe( - "FATAL: terminating connection due to idle-session timeout (SQLSTATE 57P05)", - ); + expect(terminated).toMatchObject({ + message: "FATAL: terminating connection due to idle-session timeout (SQLSTATE 57P05)", + transactionPhase: "begin", + }); const poisoned = legacyBatchFailureError( beginRejected, @@ -838,6 +845,8 @@ describe("legacyBatchFailureError", () => { expect(poisoned.message).toBe( "ERROR: canceling statement due to statement timeout (SQLSTATE 57014)", ); + // A poisoned batch never reached the server, so it stays on the statement path. + expect(poisoned).not.toHaveProperty("transactionPhase"); }); it("names the transaction commit when a deferred failure lands on COMMIT", () => { @@ -879,9 +888,10 @@ describe("legacyBatchFailureError", () => { true, ); expect(dropped).toBeInstanceOf(LegacyDbExecError); - expect(dropped.message).toBe( - "FATAL: terminating connection: database dropped (SQLSTATE 57P04)", - ); + expect(dropped).toMatchObject({ + message: "FATAL: terminating connection: database dropped (SQLSTATE 57P04)", + transactionPhase: "commit", + }); }); it("keeps server-error mapping and the completed count for a statement failure", () => { diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 0acf013ed5..56ec00e530 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -315,6 +315,33 @@ describe("legacyApplyMigrationFile", () => { ); }); + it.effect("reports a connection lost at BEGIN without blaming the first statement", () => { + // The driver marks the begin phase without relabeling the message when the + // connection died before BEGIN completed; the caller's statement never ran, + // so the formatter must surface the loss verbatim with no statement echo. + const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); + const file = join(dir, "20240101120000_begin_lost.sql"); + writeFileSync(file, "SELECT 1;"); + const { session } = fakeSession({ + failOn: "SELECT 1", + failWith: { + message: "Error: Connection terminated unexpectedly", + transactionPhase: "begin", + }, + }); + return run(session, file).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.message).toContain("Connection terminated unexpectedly"); + expect(error.message).not.toContain("At statement"); + expect(error.message).not.toContain("SELECT 1"); + }), + ), + Effect.ensuring(Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), + ); + }); + it.effect("reports a deferred commit failure without blaming a statement", () => { const dir = mkdtempSync(join(tmpdir(), "legacy-apply-")); const file = join(dir, "20240101120000_deferred.sql"); From 35fc933f2af5329a3c836de3e7c2ff17c006514c Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:50:25 +0530 Subject: [PATCH 29/33] fix: route refresh materialized view concurrently standalone --- apps/cli/src/legacy/shared/legacy-migration-apply.ts | 8 ++++++-- .../src/legacy/shared/legacy-migration-apply.unit.test.ts | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index f5cdf01521..0b5e99c285 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -77,6 +77,8 @@ const ALL_IN_TABLESPACE_PATTERN = /^ALTER\s+(?:TABLE|INDEX|MATERIALIZED\s+VIEW)\s+ALL\s+IN\s+TABLESPACE(?:\s|$)/u; const DETACH_PARTITION_PATTERN = /^ALTER\s+TABLE\s[\s\S]*\sDETACH\s+PARTITION\s[\s\S]*\sCONCURRENTLY(?:\s|$)/u; +const REFRESH_MATERIALIZED_VIEW_CONCURRENTLY_PATTERN = + /^REFRESH\s+MATERIALIZED\s+VIEW\s+CONCURRENTLY(?:\s|$)/u; const TRANSACTION_CONTROL_PATTERN = /^(?:BEGIN|START\s+TRANSACTION|COMMIT|END|ABORT|PREPARE\s+TRANSACTION)(?:\s|$)/u; @@ -116,7 +118,8 @@ const legacyTrimLeadingSqlComments = (sql: string): string => { * `ALTER DATABASE … SET TABLESPACE`, * `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, * `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`, - * `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE`. Such statements (in + * `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE`, + * `REFRESH MATERIALIZED VIEW CONCURRENTLY`. Such statements (in * their default forms) fail with * SQLSTATE 25001 inside the transaction * created by a migration batch, so `execMigrationBatch` runs them standalone. @@ -148,7 +151,8 @@ export const legacyIsPipelineIncompatible = (sql: string): boolean => { ALTER_DATABASE_TABLESPACE_PATTERN.test(upper) || ALTER_SUBSCRIPTION_REFRESH_PATTERN.test(upper) || DETACH_PARTITION_PATTERN.test(upper) || - ALL_IN_TABLESPACE_PATTERN.test(upper) + ALL_IN_TABLESPACE_PATTERN.test(upper) || + REFRESH_MATERIALIZED_VIEW_CONCURRENTLY_PATTERN.test(upper) ); }; diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index 56ec00e530..14bf5693eb 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -1289,6 +1289,12 @@ describe("legacyIsPipelineIncompatible", () => { ["alter subscription set options", "ALTER SUBSCRIPTION sub SET (slot_name = 's')", false], ["discard all", "DISCARD ALL", true], ["discard temp", "DISCARD TEMP", false], + [ + "refresh materialized view concurrently", + "REFRESH MATERIALIZED VIEW CONCURRENTLY public.mv", + true, + ], + ["plain refresh materialized view", "REFRESH MATERIALIZED VIEW public.mv", false], [ "lower-case create index concurrently", "create index concurrently widgets_id_idx on public.widgets(id)", From b0f6cba496e97ff2743dced89907a7f07c400f65 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:50:25 +0530 Subject: [PATCH 30/33] test: assert connection-loss error via instanceof --- .../shared/legacy-db-connection.sql-pg.integration.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts index 3ca1e2fd46..8bf36e78bb 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts @@ -11,7 +11,10 @@ import { describe, expect, it } from "@effect/vitest"; import { Duration, Effect } from "effect"; import { LEGACY_SUGGEST_ENV_VAR, LEGACY_SUGGEST_LOCAL_STACK } from "./legacy-connect-errors.ts"; -import type { LegacyDbConnectError, LegacyDbExecError } from "./legacy-db-connection.errors.ts"; +import { + type LegacyDbConnectError, + LegacyDbExecError, +} from "./legacy-db-connection.errors.ts"; import { type LegacyDbSession, type LegacyPgConnInput, @@ -799,7 +802,7 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { orElse: () => Effect.die("execBatch never settled after the connection died"), }), ); - expect(error._tag).toBe("LegacyDbExecError"); + expect(error).toBeInstanceOf(LegacyDbExecError); expect(asBatchExecError(error).message).toContain("Connection terminated unexpectedly"); expect(asBatchExecError(error)).toMatchObject({ statementIndex: 0, From 221515c0d217a5c987033d97feefce7c35a3c096 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Mon, 31 Aug 2026 03:57:46 +0530 Subject: [PATCH 31/33] docs: note standalone concurrent matview refresh in contracts --- apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md | 3 ++- apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md index 962112a0b2..71401c3653 100644 --- a/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/push/SIDE_EFFECTS.md @@ -123,7 +123,8 @@ stdout is payload-only. A single `result` object is emitted: `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, `ALTER DATABASE … SET TABLESPACE`, and `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`, - `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE` + `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE`, and + `REFRESH MATERIALIZED VIEW CONCURRENTLY` cannot run inside a transaction block (SQLSTATE 25001). The apply flushes (commits) the open batch, runs the statement standalone outside any transaction, then resumes batching; the history diff --git a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md index 7b6a211823..932a2bdc1e 100644 --- a/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/migration/up/SIDE_EFFECTS.md @@ -70,8 +70,9 @@ Same structured `applied` result delivered as an NDJSON `result` event. `REINDEX DATABASE`/`SYSTEM`/`SCHEMA`, `CREATE`/`DROP SUBSCRIPTION`, `DISCARD ALL`, `ALTER DATABASE … SET TABLESPACE`, `ALTER SUBSCRIPTION … REFRESH`/`SET`/`ADD`/`DROP PUBLICATION`, - `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`, and - `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE`) run standalone outside + `ALTER TABLE … DETACH PARTITION … CONCURRENTLY`, + `ALTER TABLE`/`INDEX`/`MATERIALIZED VIEW ALL IN TABLESPACE`, and + `REFRESH MATERIALIZED VIEW CONCURRENTLY`) run standalone outside the migration's transaction batch — they fail with SQLSTATE 25001 inside one. The history insert stays in the final batch, so a mid-file failure leaves earlier, already-committed batches applied with **no history row**; a re-run replays the file From 3224cb51b24b6ae279d430079dae961222f48c26 Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:08:42 +0530 Subject: [PATCH 32/33] chore: fix test import formatting --- .../shared/legacy-db-connection.sql-pg.integration.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts index 8bf36e78bb..9be4937c55 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts @@ -11,10 +11,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Duration, Effect } from "effect"; import { LEGACY_SUGGEST_ENV_VAR, LEGACY_SUGGEST_LOCAL_STACK } from "./legacy-connect-errors.ts"; -import { - type LegacyDbConnectError, - LegacyDbExecError, -} from "./legacy-db-connection.errors.ts"; +import { type LegacyDbConnectError, LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import { type LegacyDbSession, type LegacyPgConnInput, From 4917f5fa2d8b6dd8aa26314167c12b9588baccba Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:34:23 +0530 Subject: [PATCH 33/33] fix: keep rollback failures in the effect boundary --- .../legacy-db-connection.sql-pg.layer.ts | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index 7c8f37c415..d3c16fee4d 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -1184,23 +1184,17 @@ const connect = ( }).pipe( // Roll a written batch's aborted transaction back while still // interruptible; a rollback that fails or times out leaves the client - // to the discard below instead of returning it aborted (25P02). + // to the discard below instead of returning it aborted (25P02). The + // rollback's own failure is consumed as that discard policy — it must + // never supplant the batch error this tap is observing. Effect.tapError(() => Effect.suspend(() => { if (batchQuery?.outcome !== "submitted") return Effect.void; - return Effect.promise(() => { - try { - return activeClient.query("ROLLBACK").then( - () => true, - () => false, - ); - } catch { - return Promise.resolve(false); - } - }).pipe( + return Effect.tryPromise(() => activeClient.query("ROLLBACK")).pipe( + Effect.match({ onFailure: () => false, onSuccess: () => true }), Effect.timeoutOption(1000), - Effect.map((result) => { - rolledBack = Option.getOrElse(result, () => false); + Effect.map((completed) => { + rolledBack = Option.getOrElse(completed, () => false); }), ); }),