Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
30ba92c
fix(cli): wrap pipelined migration batches in an explicit transaction…
7ttp Aug 26, 2026
7a71865
fix: run transaction-prohibited ddl standalone
7ttp Aug 27, 2026
57f3ebb
docs: align push side effects with transactional batches
7ttp Aug 27, 2026
ce99a40
test: assert batch errors via instanceof
7ttp Aug 27, 2026
32335b4
fix: route subscription ddl standalone
7ttp Aug 27, 2026
5afa6f7
fix: roll failed batches back while interruptible
7ttp Aug 27, 2026
8335bff
fix: run discard all standalone with role restore
7ttp Aug 27, 2026
7f55c99
fix: route reindex schema standalone
7ttp Aug 27, 2026
35acc1b
fix: route alter database set tablespace standalone
7ttp Aug 27, 2026
605f369
fix: route subscription refresh forms standalone
7ttp Aug 27, 2026
a6bd732
docs: align migration up standalone list
7ttp Aug 27, 2026
9aece2f
fix: allocate batch state per execution
7ttp Aug 27, 2026
9cea8df
fix: address batch review nits
7ttp Aug 29, 2026
f2f254d
fix: tighten detach partition matching
7ttp Aug 29, 2026
0a7172a
fix: route all-in-tablespace moves standalone
7ttp Aug 29, 2026
5b85ecc
fix: match qualified quoted detach names
7ttp Aug 29, 2026
eae412a
fix: treat database-dropped as session-ending
7ttp Aug 29, 2026
1ad16ee
fix: keep pipeline classifiers loose and uniform
7ttp Aug 29, 2026
b1b3405
fix: name commit failures in batch errors
7ttp Aug 29, 2026
79cf215
fix: polish batch classifier and rollback nits
7ttp Aug 29, 2026
d582b79
fix: report commit failures without a statement tail
7ttp Aug 29, 2026
3e9d1e5
fix: omit statement context for begin failures
7ttp Aug 29, 2026
c838da3
fix: match parenthesized reindex concurrently
7ttp Aug 29, 2026
02db922
fix: keep detach partition finalize batched
7ttp Aug 29, 2026
7ddeddc
docs: note wrapper failures in reset contract
7ttp Aug 29, 2026
abf4aff
fix: type transaction phase without assertions
7ttp Aug 29, 2026
f83f672
docs: note roles batch splits in push contract
7ttp Aug 29, 2026
d3beb8d
fix: mark wrapper phase on connection loss
7ttp Aug 30, 2026
35fc933
fix: route refresh materialized view concurrently standalone
7ttp Aug 30, 2026
b0f6cba
test: assert connection-loss error via instanceof
7ttp Aug 30, 2026
221515c
docs: note standalone concurrent matview refresh in contracts
7ttp Aug 30, 2026
3224cb5
chore: fix test import formatting
7ttp Aug 30, 2026
a270457
Merge branch 'develop' into 7ttp/cli-2261-db-reset-pipelined-migratio…
7ttp Aug 31, 2026
4917f5f
fix: keep rollback failures in the effect boundary
7ttp Aug 31, 2026
9a5ba18
Merge branch 'develop' into 7ttp/cli-2261-db-reset-pipelined-migratio…
7ttp Sep 7, 2026
4c3f1b8
chore: merge develop to resolve legacy-prefix rename conflicts (#6525)
Coly010 Sep 8, 2026
2007a82
chore: fix formatting after develop merge (#6525)
Coly010 Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/cli/src/command-internal/db-connection.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export class DbExecError extends Data.TaggedError("DbExecError")<{
* the batch length for a deferred Sync failure. Absent for `exec`/`query`.
*/
readonly statementIndex?: number;
/**
* 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";
/**
* 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
Expand Down
9 changes: 6 additions & 3 deletions apps/cli/src/command-internal/db-connection.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,12 @@ export interface DbSession {
/** Run a single SQL statement, ignoring any returned rows. */
readonly exec: (sql: string) => Effect.Effect<void, DbExecError>;
/**
* Run statements as one extended-protocol batch with a single final Sync.
* On failure, {@link DbExecError.statementIndex} is the number of
* statements that completed before the error.
* 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 a bounded, best-effort rollback runs
* before the connection can be reused (a rollback that does not succeed
* discards the connection), and {@link DbExecError.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { describe, expect, it } from "@effect/vitest";
import { Duration, Effect } from "effect";

import { SUGGEST_ENV_VAR, SUGGEST_LOCAL_STACK } from "./connect-errors.ts";
import type { DbConnectError, DbExecError } from "./db-connection.errors.ts";
import { type DbConnectError, DbExecError } from "./db-connection.errors.ts";
import { type DbSession, type PgConnInput, DbConnection } from "./db-connection.service.ts";
import {
acquirePgPool,
Expand Down Expand Up @@ -162,6 +162,7 @@ interface FakeBatchServerState {
readonly frameTypes: Array<string>;
readonly statements: Array<string>;
readonly params: Array<ReadonlyArray<string | null>>;
readonly simpleQueries: Array<string>;
syncs: number;
}

Expand All @@ -173,8 +174,12 @@ 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;
/** Drop the connection at the first Execute (BEGIN's), before anything completes. */
readonly destroyOnFirstExecute?: boolean;
} = {},
): Promise<{
readonly port: number;
Expand All @@ -187,9 +192,11 @@ const fakeBatchServer = (
frameTypes: [],
statements: [],
params: [],
simpleQueries: [],
syncs: 0,
};
const sockets: Array<net.Socket> = [];
let destroyedOnExecute = false;
const server = net.createServer((socket) => {
sockets.push(socket);
let sawStartup = false;
Expand Down Expand Up @@ -219,6 +226,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;
}
Expand Down Expand Up @@ -250,6 +264,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;
Expand Down Expand Up @@ -547,9 +566,9 @@ describe("dbConnectionSqlPgLayer 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", () =>
Comment thread
7ttp marked this conversation as resolved.
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([
Expand All @@ -562,18 +581,22 @@ describe("dbConnectionSqlPgLayer 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([
[],
[],
[],
[
"v'1",
"name\\two",
'{"plain","quote\\\"","slash\\\\","comma,","{brace}","line\nbreak","NULL",""}',
],
[],
]);
expect(server.state.frameTypes).toEqual([
"P",
Expand All @@ -588,15 +611,24 @@ describe("dbConnectionSqlPgLayer 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(
Expand All @@ -617,7 +649,7 @@ describe("dbConnectionSqlPgLayer 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" }])
Expand Down Expand Up @@ -655,6 +687,72 @@ describe("dbConnectionSqlPgLayer 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 error-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.
Expand All @@ -671,6 +769,35 @@ describe("dbConnectionSqlPgLayer extended batches", () => {
);
expect(error._tag).toBe("DbExecError");
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).toBeInstanceOf(DbExecError);
expect(asBatchExecError(error).message).toContain("Connection terminated unexpectedly");
expect(asBatchExecError(error)).toMatchObject({
statementIndex: 0,
transactionPhase: "begin",
});
yield* session.execBatch([{ sql: "SELECT 3" }]);
}),
);
Expand Down
Loading
Loading