diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts index b01206e674..6a67e8d0aa 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.integration.test.ts @@ -207,6 +207,7 @@ function setup(opts: SetupOpts = {}) { copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), exec: () => Effect.void, + execBatch: () => Effect.void, query: () => Effect.succeed([]), }); }), diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index f01f8d0f12..b85362575f 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -168,6 +168,7 @@ describe("legacy bootstrap linked-project cache location", () => { copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), exec: () => Effect.void, + execBatch: () => Effect.void, query: () => Effect.succeed([]), }; }), diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.integration.test.ts index f46a88aa74..c2165fcff8 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.integration.test.ts @@ -10,6 +10,7 @@ import { mockLegacyLinkedProjectCacheTracked, mockLegacyPlatformApi, mockLegacyTelemetryStateTracked, + legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -29,6 +30,7 @@ import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.t import { LegacyDbConnection, type LegacyPgConnInput, + type LegacyDbSession, } from "../../../shared/legacy-db-connection.service.ts"; import { LegacyDbAdvisorsFailOnError, @@ -103,8 +105,8 @@ function mockConnection(opts: { }) { const execs: Array = []; const layer = Layer.succeed(LegacyDbConnection, { - connect: () => - Effect.succeed({ + connect: () => { + const session: LegacyDbSession = { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), @@ -123,7 +125,12 @@ function mockConnection(opts: { } return Effect.succeed(opts.rows ?? []); }), - }), + // A migration file's statements arrive as one batch; replay them through + // `exec`/`query` so this suite's recordings and failure injection still apply. + execBatch: (statements) => legacySequentialExecBatch(session)(statements), + }; + return Effect.succeed(session); + }, }); return { layer, diff --git a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts index d2f0ae0564..9d9fcff873 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.integration.test.ts @@ -16,6 +16,7 @@ import { mockLegacyShadowContainerCliSpawner, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, + legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockRuntimeInfo } from "../../../../../tests/helpers/mocks.ts"; import { dockerfileServiceImage } from "../../../../shared/services/dockerfile-images.ts"; @@ -149,6 +150,7 @@ function fakeShadowDbConnection() { execCalls.push(sql); }), query: () => Effect.succeed([]), + execBatch: (statements) => legacySequentialExecBatch(session)(statements), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), diff --git a/apps/cli/src/legacy/commands/db/lint/lint.integration.test.ts b/apps/cli/src/legacy/commands/db/lint/lint.integration.test.ts index 63e2aa49ff..d69277424e 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.integration.test.ts @@ -6,6 +6,7 @@ import { LEGACY_VALID_REF, mockLegacyLinkedProjectCacheTracked, mockLegacyTelemetryStateTracked, + legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -19,6 +20,7 @@ import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.t import { LegacyDbConnection, type LegacyPgConnInput, + type LegacyDbSession, } from "../../../shared/legacy-db-connection.service.ts"; import { LegacyDbLintFailOnError } from "./lint.errors.ts"; import { encodeLegacyLintResults, parseLegacyLintResult } from "./lint.format.ts"; @@ -81,8 +83,8 @@ function mockConnection(opts: { const linted: Array = []; let listParams: ReadonlyArray | undefined; const layer = Layer.succeed(LegacyDbConnection, { - connect: () => - Effect.succeed({ + connect: () => { + const session: LegacyDbSession = { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), @@ -122,7 +124,12 @@ function mockConnection(opts: { } return Effect.succeed([]); }), - }), + // A migration file's statements arrive as one batch; replay them through + // `exec`/`query` so this suite's recordings and failure injection still apply. + execBatch: (statements) => legacySequentialExecBatch(session)(statements), + }; + return Effect.succeed(session); + }, }); return { layer, diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 412cea0a16..53db82f60e 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -37,7 +37,10 @@ import { PROJECT_NOT_LINKED_MESSAGE, } from "../../../config/legacy-project-ref.service.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; -import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { + type LegacyDbSession, + LegacyDbConnection, +} from "../../../shared/legacy-db-connection.service.ts"; import { LegacyDockerRun, type LegacyDockerRunOpts, @@ -327,19 +330,30 @@ function setup(workdir: string, opts: SetupOpts = {}) { // ALSO issues a parameterized `INSERT_MIGRATION_VERSION` query, into its own // separate in-shadow history table). const TARGET_PORT = 5432; - const makeSession = (isShadow: boolean) => ({ - exec: (sql: string) => Effect.sync(() => void execLog.push(sql)), - query: (sql: string, params?: ReadonlyArray) => { + const makeSession = (isShadow: boolean): LegacyDbSession => { + const exec = (sql: string) => Effect.sync(() => void execLog.push(sql)); + const query = (sql: string, params?: ReadonlyArray) => { if (/SELECT version/u.test(sql)) { return Effect.succeed((opts.remoteVersions ?? []).map((v) => ({ version: v }))); } if (!isShadow && params !== undefined) historyUpserts.push(params); return Effect.succeed([] as ReadonlyArray>); - }, - extensionExists: () => Effect.die("extensionExists unused"), - copyToCsv: () => Effect.die("copyToCsv unused"), - queryRaw: () => Effect.die("queryRaw unused"), - }); + }; + return { + exec, + query, + // A migration batch carries exactly the statements (and the parameterized + // history insert) the sequential path would run, so route each operation + // through the same recording. + execBatch: (statements) => + Effect.forEach(statements, ({ sql, params }) => + params === undefined ? exec(sql) : query(sql, params), + ).pipe(Effect.asVoid), + extensionExists: () => Effect.die("extensionExists unused"), + copyToCsv: () => Effect.die("copyToCsv unused"), + queryRaw: () => Effect.die("queryRaw unused"), + }; + }; const targetSession = makeSession(false); const shadowSession = makeSession(true); const dbConnection = Layer.succeed(LegacyDbConnection, { diff --git a/apps/cli/src/legacy/commands/db/pull/pull.sync.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.sync.integration.test.ts index f626ab565e..333f6398f0 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.sync.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.sync.integration.test.ts @@ -20,6 +20,9 @@ function mockSession(opts: { readonly failUpsertAt?: number } = {}) { let upsertCount = 0; const session: LegacyDbSession = { exec: (sql: string) => Effect.sync(() => void calls.push(sql)), + // `legacyUpdateMigrationHistory` owns its transaction envelope statement by + // statement; it never batches. + execBatch: () => Effect.die("execBatch unused"), query: (sql: string) => { if (/INSERT INTO supabase_migrations/u.test(sql)) { upsertCount += 1; diff --git a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts index 55bea9eece..f8b411d56c 100644 --- a/apps/cli/src/legacy/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/push/push.integration.test.ts @@ -13,6 +13,7 @@ import { mockLegacyLinkedProjectCacheTracked, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, + legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag, LegacyYesFlag } from "../../../../shared/legacy/global-flags.ts"; @@ -28,6 +29,7 @@ import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.t import { LegacyDbConnection, type LegacyPgConnInput, + type LegacyDbSession, } from "../../../shared/legacy-db-connection.service.ts"; import { LegacyEdgeRuntimeScriptError } from "../../../shared/legacy-edge-runtime-script.errors.ts"; import { @@ -96,8 +98,8 @@ function mockConnection(opts: { const execs: Array = []; const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; const layer = Layer.succeed(LegacyDbConnection, { - connect: () => - Effect.succeed({ + connect: () => { + const session: LegacyDbSession = { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), @@ -144,7 +146,12 @@ function mockConnection(opts: { return Effect.succeed([]); }, ), - }), + // A migration file's statements arrive as one batch; replay them through + // `exec`/`query` so this suite's recordings and failure injection still apply. + execBatch: (statements) => legacySequentialExecBatch(session)(statements), + }; + return Effect.succeed(session); + }, }); return { layer, diff --git a/apps/cli/src/legacy/commands/db/query/query.integration.test.ts b/apps/cli/src/legacy/commands/db/query/query.integration.test.ts index 05e0f0a143..6b54b09ca1 100644 --- a/apps/cli/src/legacy/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/query/query.integration.test.ts @@ -81,6 +81,7 @@ function mockDbConnection(opts: { connect: () => Effect.succeed({ exec: () => Effect.void, + execBatch: () => Effect.void, query: () => Effect.succeed([]), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), diff --git a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts index 8924889d0c..4addec1d0e 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.integration.test.ts @@ -22,6 +22,7 @@ import { mockLegacyPlatformApiService, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, + legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; @@ -56,6 +57,7 @@ import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.t import { LegacyDbConnection, type LegacyPgConnInput, + type LegacyDbSession, } from "../../../shared/legacy-db-connection.service.ts"; import { legacyDbReset } from "./reset.handler.ts"; import type { LegacyDbResetFlags } from "./reset.command.ts"; @@ -163,8 +165,8 @@ function mockConnection( const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; let replicationCallIndex = 0; const layer = Layer.succeed(LegacyDbConnection, { - connect: () => - Effect.succeed({ + connect: () => { + const session: LegacyDbSession = { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), @@ -211,7 +213,12 @@ function mockConnection( return Effect.succeed([]); }, ), - }), + // A migration file's statements arrive as one batch; replay them through + // `exec`/`query` so this suite's recordings and failure injection still apply. + execBatch: (statements) => legacySequentialExecBatch(session)(statements), + }; + return Effect.succeed(session); + }, }); return { layer, diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts index 47788d9346..62c85f1f7c 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.orchestrate.integration.test.ts @@ -82,6 +82,7 @@ function mockShadowInfra() { connectedDatabases.push(cfg.database); const session: LegacyDbSession = { exec: () => Effect.void, + execBatch: () => Effect.void, query: () => Effect.succeed([]), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts index 49787250b1..74c0597144 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -26,6 +26,7 @@ import { mockLegacyPlatformApiService, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, + legacySequentialExecBatch, } from "../../../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../../../shared/cli/cli-args.service.ts"; import { @@ -40,7 +41,10 @@ import { LegacyPlatformApi } from "../../../../../auth/legacy-platform-api.servi import { LegacyPlatformApiFactory } from "../../../../../auth/legacy-platform-api-factory.service.ts"; import { legacyDockerRunLayer } from "../../../../../shared/legacy-docker-run.layer.ts"; import { LegacyDbConfigResolver } from "../../../../../shared/legacy-db-config.service.ts"; -import { LegacyDbConnection } from "../../../../../shared/legacy-db-connection.service.ts"; +import { + type LegacyDbSession, + LegacyDbConnection, +} from "../../../../../shared/legacy-db-connection.service.ts"; import { type LegacyEdgeRuntimeRunOpts, LegacyEdgeRuntimeScript, @@ -112,8 +116,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { ); const dbExec: string[] = []; const dbConn = Layer.succeed(LegacyDbConnection, { - connect: () => - Effect.succeed({ + connect: () => { + const session: LegacyDbSession = { exec: (sql: string) => Effect.sync(() => { dbExec.push(sql); @@ -126,7 +130,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - }), + // A migration file's statements arrive as one batch; replay them through + // `exec`/`query` so this suite's recordings and failure injection still apply. + execBatch: (statements) => legacySequentialExecBatch(session)(statements), + }; + return Effect.succeed(session); + }, }); const seam = Layer.succeed(LegacyDeclarativeSeam, { exportCatalog: ({ mode, projectRef }) => { diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts index 9171739c15..ec0927af3c 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.seam.integration.test.ts @@ -58,6 +58,7 @@ function fakeShadowDbConnection() { Effect.sync(() => { const session: LegacyDbSession = { exec: () => Effect.void, + execBatch: () => Effect.void, query: () => Effect.succeed([]), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts index bfc54450bf..19fbf800b1 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-shadow-source.ts @@ -643,6 +643,18 @@ function legacyMigrateBaseDatabase( path, absolutePaths, (message) => new LegacyDeclarativeShadowDbError({ message }), + ).pipe( + // A batch runs on its own pooled connection, so failing to acquire it is a + // connection failure, not a statement failure: it wears this seam's own error + // class (like the `connect` mapping above) and keeps the driver's suggestion. + Effect.catchTag("LegacyDbConnectError", (cause) => + Effect.fail( + new LegacyDeclarativeShadowDbError({ + message: cause.message, + ...(cause.suggestion === undefined ? {} : { suggestion: cause.suggestion }), + }), + ), + ), ); }), ); diff --git a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts index 94ef016d04..5f5244be1f 100644 --- a/apps/cli/src/legacy/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/start/start.integration.test.ts @@ -18,6 +18,7 @@ import { mockLegacyCliConfig, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, + legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { @@ -248,6 +249,7 @@ function fakeDbSession() { calls.push({ kind: "query", sql }); return []; }), + execBatch: (statements) => legacySequentialExecBatch(session)(statements), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), diff --git a/apps/cli/src/legacy/commands/db/test/test.integration.test.ts b/apps/cli/src/legacy/commands/db/test/test.integration.test.ts index 38fd0c105c..2e18a09c61 100644 --- a/apps/cli/src/legacy/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/test/test.integration.test.ts @@ -48,6 +48,7 @@ import { import { mockLegacyCliConfig, mockLegacyTelemetryStateTracked, + legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; @@ -124,6 +125,7 @@ function mockDbConnection() { Effect.sync(() => { execCalls.push(sql); }), + execBatch: (statements) => legacySequentialExecBatch(session)(statements), extensionExists: () => Effect.succeed(false), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), copyToCsv: () => Effect.succeed(new Uint8Array()), diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts index 55c1c4793e..1ab158ec48 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts @@ -61,6 +61,7 @@ function setup() { connect: () => Effect.succeed({ exec: () => Effect.void, + execBatch: () => Effect.void, extensionExists: () => Effect.succeed(false), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), copyToCsv: () => Effect.succeed(new Uint8Array()), diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts index 2e83f36d2c..41c6a5098e 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts @@ -110,6 +110,7 @@ function mockDbConnection(opts: { } return Effect.succeed({ exec: () => Effect.void, + execBatch: () => Effect.void, extensionExists: () => Effect.succeed(false), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), copyToCsv: () => Effect.succeed(new Uint8Array()), diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts index 4ed65417fe..14d58b7f06 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts @@ -52,6 +52,7 @@ function setup(rows: ReadonlyArray>) { connect: () => Effect.succeed({ exec: () => Effect.void, + execBatch: () => Effect.void, extensionExists: () => Effect.succeed(false), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), copyToCsv: () => Effect.succeed(new Uint8Array()), diff --git a/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts b/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts index e7a95a7559..ef0fe2a072 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.integration.test.ts @@ -91,6 +91,7 @@ function mockReportConnection(opts: { } return Effect.succeed({ exec: () => Effect.void, + execBatch: () => Effect.void, extensionExists: () => Effect.succeed(false), query: () => Effect.succeed([]), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), diff --git a/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts b/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts index 92a5e14a4a..6f1acb1bca 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.integration.test.ts @@ -12,6 +12,7 @@ import { mockLegacyLinkedProjectCacheTracked, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, + legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; @@ -25,7 +26,10 @@ import type { LegacyResolvedDbConfig, } from "../../../shared/legacy-db-config.types.ts"; import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; -import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { + type LegacyDbSession, + LegacyDbConnection, +} from "../../../shared/legacy-db-connection.service.ts"; import { LegacyMigrationDropError } from "../../../shared/legacy-drop-objects.ts"; import { LegacyMigrationSeedError } from "../../../shared/legacy-seed.ts"; import { legacyMigrationDown } from "./down.handler.ts"; @@ -87,8 +91,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const connection = Layer.succeed(LegacyDbConnection, { - connect: () => - Effect.succeed({ + connect: () => { + const session: LegacyDbSession = { exec: (sql: string) => Effect.suspend(() => { execs.push(sql); @@ -111,7 +115,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - }), + // A migration file's statements arrive as one batch; replay them through + // `exec`/`query` so this suite's recordings and failure injection still apply. + execBatch: (statements) => legacySequentialExecBatch(session)(statements), + }; + return Effect.succeed(session); + }, }); // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts index 188b419f19..1af8b5b187 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.integration.test.ts @@ -81,6 +81,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { connect: () => Effect.succeed({ exec: () => Effect.void, + execBatch: () => Effect.void, query: (sql: string) => Effect.suspend(() => sql === SELECT_SQL diff --git a/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts b/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts index 618740a95d..c5eccee980 100644 --- a/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/list/list.integration.test.ts @@ -66,6 +66,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { connect: () => Effect.succeed({ exec: () => Effect.void, + execBatch: () => Effect.void, query: (sql: string) => Effect.suspend(() => { if (sql === LIST_SQL) { diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts b/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts index fa14041e82..34ed94bb7b 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.integration.test.ts @@ -11,6 +11,7 @@ import { mockLegacyLinkedProjectCacheTracked, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, + legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; @@ -24,7 +25,10 @@ import type { LegacyResolvedDbConfig, } from "../../../shared/legacy-db-config.types.ts"; import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; -import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { + type LegacyDbSession, + LegacyDbConnection, +} from "../../../shared/legacy-db-connection.service.ts"; import { legacyMigrationRepair, type LegacyMigrationRepairInput } from "./repair.handler.ts"; interface SetupOpts { @@ -72,8 +76,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const connection = Layer.succeed(LegacyDbConnection, { - connect: () => - Effect.succeed({ + connect: () => { + const session: LegacyDbSession = { exec: (sql: string) => Effect.suspend(() => { execs.push(sql); @@ -91,7 +95,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - }), + // A migration file's statements arrive as one batch; replay them through + // `exec`/`query` so this suite's recordings and failure injection still apply. + execBatch: (statements) => legacySequentialExecBatch(session)(statements), + }; + return Effect.succeed(session); + }, }); // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same diff --git a/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts index cac1d8f1d9..5d1f5dcb75 100644 --- a/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/squash/squash.integration.test.ts @@ -16,6 +16,7 @@ import { mockLegacyShadowContainerCliSpawner, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, + legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput, @@ -286,6 +287,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { ? Effect.fail(new LegacyDbExecError({ message: "boom" })) : Effect.succeed>>([]); }), + // A migration file's statements arrive as one batch; replay them through + // `exec`/`query` so the call-order log and failure injection still apply. + execBatch: (batch) => legacySequentialExecBatch(session)(batch), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), diff --git a/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts b/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts index 27979f452d..e903a225c2 100644 --- a/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/legacy/commands/migration/up/up.integration.test.ts @@ -11,6 +11,7 @@ import { mockLegacyLinkedProjectCacheTracked, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, + legacySequentialExecBatch, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; @@ -23,7 +24,10 @@ import type { LegacyResolvedDbConfig, } from "../../../shared/legacy-db-config.types.ts"; import { LegacyDbExecError } from "../../../shared/legacy-db-connection.errors.ts"; -import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { + type LegacyDbSession, + LegacyDbConnection, +} from "../../../shared/legacy-db-connection.service.ts"; import { LegacyMigrationVaultError } from "../../../shared/legacy-vault.ts"; import { legacyMigrationUp } from "./up.handler.ts"; import type { LegacyMigrationUpFlags } from "./up.command.ts"; @@ -69,8 +73,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const connection = Layer.succeed(LegacyDbConnection, { - connect: () => - Effect.succeed({ + connect: () => { + const session: LegacyDbSession = { exec: (sql: string) => Effect.suspend(() => { execs.push(sql); @@ -94,7 +98,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - }), + // A migration file's statements arrive as one batch; replay them through + // `exec`/`query` so this suite's recordings and failure injection still apply. + execBatch: (statements) => legacySequentialExecBatch(session)(statements), + }; + return Effect.succeed(session); + }, }); // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same diff --git a/apps/cli/src/legacy/commands/start/start.integration.test.ts b/apps/cli/src/legacy/commands/start/start.integration.test.ts index 94132e5939..3a73df184d 100644 --- a/apps/cli/src/legacy/commands/start/start.integration.test.ts +++ b/apps/cli/src/legacy/commands/start/start.integration.test.ts @@ -22,6 +22,7 @@ import { mockLegacyCliConfig, mockLegacyTelemetryStateTracked, useLegacyTempWorkdir, + legacySequentialExecBatch, } from "../../../../tests/helpers/legacy-mocks.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; @@ -378,6 +379,7 @@ function fakeDbSession() { calls.push({ kind: "query", sql }); return []; }), + execBatch: (statements) => legacySequentialExecBatch(session)(statements), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts index 510f9dee39..9378a2d360 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.ts @@ -1032,7 +1032,13 @@ export const legacySetupDatabase = ( options: LegacySetupDatabaseOptions = {}, ): Effect.Effect< void, - LegacyDbSetupError | LegacyMigrationVaultError | LegacyImagePrepullError, + | LegacyDbSetupError + | LegacyMigrationVaultError + | LegacyImagePrepullError + // A batched SQL file whose pooled connection cannot be acquired fails with the + // driver's own connect error, surfaced verbatim (never relabeled as a setup + // failure) like every other connection failure on this path. + | LegacyDbConnectError, Output | LegacyDockerRun | RuntimeInfo > => Effect.gen(function* () { @@ -1118,7 +1124,10 @@ export const legacyStartSetupLocalDatabase = ( input: LegacyStartSetupLocalDatabaseInput, ): Effect.Effect< void, - LegacyStartSetupLocalDatabaseError, + // `LegacyDbConnectError` rides alongside the alias (as in `legacyRunFreshDbSetup`) + // because a batch that cannot check a connection out of the pool fails with the + // driver's connect error verbatim, suggestion included. + LegacyStartSetupLocalDatabaseError | LegacyDbConnectError, | Output | LegacyDockerRun | RuntimeInfo diff --git a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts index 12fd879112..0c3d2bad12 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/db-setup.unit.test.ts @@ -59,6 +59,14 @@ function fakeSession() { Effect.sync(() => { calls.push({ kind: "exec", sql }); }), + // A batched file records the same way a statement-at-a-time run would, so the + // fingerprint assertions below read the SQL regardless of how it was sent. + execBatch: (statements) => + Effect.sync(() => { + for (const { sql, params } of statements) { + calls.push(params === undefined ? { kind: "exec", sql } : { kind: "query", sql, params }); + } + }), query: (sql, params) => Effect.sync(() => { calls.push({ kind: "query", sql, params }); @@ -935,6 +943,10 @@ describe("legacyRunDatabaseWebhooksSetup", () => { Effect.sync(() => { execSql.push(sql); }), + execBatch: (statements) => + Effect.sync(() => { + for (const { sql } of statements) execSql.push(sql); + }), query: (sql) => sql.includes("supabase_migrations.schema_migrations") ? opts.historyUnavailable === true diff --git a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.unit.test.ts index c57b08ad80..6c2e3051bf 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/recreate-local-database.unit.test.ts @@ -26,6 +26,7 @@ function mockSession(opts: { let callIndex = 0; const session: LegacyDbSession = { exec: () => Effect.void, + execBatch: () => Effect.void, query: (sql): Effect.Effect>, LegacyDbExecError> => Effect.suspend(() => { queries.push(sql); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts index 0fe542c3e7..dd31042f69 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.ts @@ -554,7 +554,14 @@ export const legacySetupShadowConn = ( Output | LegacyDockerRun | RuntimeInfo > => Effect.gen(function* () { - yield* legacySetupDatabase(spawner, input, options); + yield* legacySetupDatabase(spawner, input, options).pipe( + // The baseline's batched SQL files check their own connection out of the pool; + // failing to acquire one is a shadow CONNECT failure, like + // `legacyConnectShadowDatabase`'s, never a setup/statement failure. + Effect.catchTag("LegacyDbConnectError", (cause) => + Effect.fail(new LegacyShadowDbError({ message: cause.message, reason: "connect" })), + ), + ); yield* input.session.exec(LEGACY_SHADOW_CREATE_TEMPLATE_SQL).pipe( Effect.mapError( (cause) => @@ -720,6 +727,13 @@ const migrateShadowDatabase = ( input.path, pending, (message) => new LegacyShadowDbError({ message, reason: "database" }), + ).pipe( + // A batch runs on its own pooled connection: failing to acquire it is a + // shadow CONNECT failure (same classification as `legacyConnectShadowDatabase`), + // never a `"database"` statement failure. + Effect.catchTag("LegacyDbConnectError", (cause) => + Effect.fail(new LegacyShadowDbError({ message: cause.message, reason: "connect" })), + ), ); }), ); diff --git a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts index 10fe3c93ec..7249ea4ab0 100644 --- a/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts +++ b/apps/cli/src/legacy/shared/db-bootstrap/shadow-database.unit.test.ts @@ -44,6 +44,10 @@ function fakeSession() { Effect.sync(() => { calls.push({ kind: "exec", sql }); }), + execBatch: (statements) => + Effect.sync(() => { + for (const { sql } of statements) calls.push({ kind: "exec", sql }); + }), query: (sql) => Effect.sync(() => { calls.push({ kind: "query", sql }); diff --git a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts index 1a37ac0d34..6fc666c141 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.integration.test.ts @@ -422,6 +422,7 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { }> = []; const session: LegacyDbSession = { exec: () => Effect.void, + execBatch: () => Effect.void, query: () => Effect.succeed([]), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), @@ -575,6 +576,7 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { }> = []; const session: LegacyDbSession = { exec: () => Effect.void, + execBatch: () => Effect.void, query: () => Effect.succeed([]), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), @@ -730,6 +732,7 @@ describe("legacyDbConfigResolver (linked config ordering)", () => { }> = []; const session: LegacyDbSession = { exec: () => Effect.void, + execBatch: () => Effect.void, query: () => Effect.succeed([]), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), 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 c5da489124..73d471b2a6 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.service.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.service.ts @@ -101,10 +101,15 @@ export interface LegacyDbSession { * Run statements as one extended-protocol batch with a single final Sync. * On failure, {@link LegacyDbExecError.statementIndex} is the number of * 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 raises `LegacyDbConnectError` (a connection-setup + * failure, surfaced verbatim — not masked as an exec error), consistent with + * {@link queryRaw}; only the batch's own execution raises `LegacyDbExecError`. */ - readonly execBatch?: ( + readonly execBatch: ( statements: ReadonlyArray, - ) => Effect.Effect; + ) => Effect.Effect; /** * Run a parameterized SQL query and return the result rows as plain objects * keyed by the query's column names (snake_case is preserved — the driver 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 a6de9eb86b..1193d02ebf 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 @@ -8,7 +8,7 @@ */ import * as net from "node:net"; import { describe, expect, it } from "@effect/vitest"; -import { Effect } from "effect"; +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"; @@ -174,6 +174,8 @@ const fakeBatchServer = ( readonly failExecuteAt?: number; readonly failOnSync?: boolean; readonly emptyAt?: number; + /** Never answer an extended-protocol frame, so a batch hangs until interrupted. */ + readonly stall?: boolean; } = {}, ): Promise<{ readonly port: number; @@ -219,6 +221,7 @@ const fakeBatchServer = ( continue; } state.frameTypes.push(type); + if (options.stall === true) continue; if (type === "P") { activeIndex += 1; const [, sqlOffset] = readCString(body, 0); @@ -506,6 +509,16 @@ describe("legacyDbConnectionSqlPgLayer exec failures", () => { }); describe("legacyDbConnectionSqlPgLayer extended batches", () => { + /** + * Narrow a batch failure to its statement-execution error. `execBatch` also fails + * with `LegacyDbConnectError` when it cannot check a connection out of the pool, + * which the server-side statement failures below never produce. + */ + const asBatchExecError = (error: LegacyDbConnectError | LegacyDbExecError): LegacyDbExecError => { + if (error._tag === "LegacyDbExecError") return error; + throw new Error(`expected a batch exec failure, got ${error._tag}`); + }; + const runWithBatchServer = ( server: Awaited>, use: (session: LegacyDbSession) => Effect.Effect, @@ -519,6 +532,8 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { user: "postgres", password: "postgres", database: "postgres", + // Set so a batch-connection failure can assert the suggestion survives. + suggestionContext: SUGGESTION_CONTEXT, }, { isLocal: true, dnsResolver: "native" }, ); @@ -533,17 +548,16 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { Effect.gen(function* () { const server = yield* Effect.promise(() => fakeBatchServer({ emptyAt: 1 })); const values = ["plain", 'quote"', "slash\\", "comma,", "{brace}", "line\nbreak", "NULL", ""]; - yield* runWithBatchServer(server, (session) => { - if (session.execBatch === undefined) return Effect.die("execBatch is unavailable"); - return session.execBatch([ + yield* runWithBatchServer(server, (session) => + session.execBatch([ { sql: "SELECT 1" }, { sql: "-- comment only" }, { sql: "INSERT INTO history(version, name, statements) VALUES($1, $2, $3)", params: ["v'1", "name\\two", values], }, - ]); - }); + ]), + ); expect(server.state.statements).toEqual([ "SELECT 1", "-- comment only", @@ -580,22 +594,20 @@ describe("legacyDbConnectionSqlPgLayer extended batches", () => { 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 })); - yield* runWithBatchServer(server, (session) => { - const execBatch = session.execBatch; - if (execBatch === undefined) return Effect.die("execBatch is unavailable"); - return Effect.gen(function* () { - const error = yield* execBatch([ - { sql: "SELECT 1" }, - { sql: "-- comment only" }, - { sql: "SELECT bad" }, - ]).pipe(Effect.flip); + yield* runWithBatchServer(server, (session) => + Effect.gen(function* () { + const error = asBatchExecError( + yield* session + .execBatch([{ sql: "SELECT 1" }, { sql: "-- comment only" }, { sql: "SELECT bad" }]) + .pipe(Effect.flip), + ); expect(error.statementIndex).toBe(2); expect(error.code).toBe("42601"); expect(error.detail).toBe("batch detail"); expect(error.position).toBe(10); yield* session.exec("SELECT after_error"); - }); - }); + }), + ); expect(server.state.syncs).toBe(1); }), ); @@ -603,40 +615,69 @@ 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 })); - yield* runWithBatchServer(server, (session) => { - if (session.execBatch === undefined) return Effect.die("execBatch is unavailable"); - return session + yield* runWithBatchServer(server, (session) => + session .execBatch([{ sql: "SELECT 1" }, { sql: "INSERT duplicate" }, { sql: "SELECT 3" }]) .pipe( Effect.flip, - Effect.tap((error) => + Effect.tap((cause) => Effect.sync(() => { + const error = asBatchExecError(cause); expect(error.statementIndex).toBe(1); expect(error.code).toBe("23505"); expect(error.detail).toBe("runtime detail"); expect(error.position).toBeUndefined(); }), ), - ); - }); + ), + ); }), ); it.live("reports a deferred Sync failure after every completed statement", () => Effect.gen(function* () { const server = yield* Effect.promise(() => fakeBatchServer({ failOnSync: true })); - yield* runWithBatchServer(server, (session) => { - if (session.execBatch === undefined) return Effect.die("execBatch is unavailable"); - return session.execBatch([{ sql: "SELECT 1" }, { sql: "SELECT 2" }]).pipe( + yield* runWithBatchServer(server, (session) => + session.execBatch([{ sql: "SELECT 1" }, { sql: "SELECT 2" }]).pipe( Effect.flip, - Effect.tap((error) => + Effect.tap((cause) => Effect.sync(() => { + const error = asBatchExecError(cause); expect(error.statementIndex).toBe(2); expect(error.code).toBe("23514"); }), ), + ), + ); + }), + ); + + it.live("classifies a failed batch-connection acquisition as a connect error", () => + // A batch checks its own connection out of the pool, so a refused checkout is a + // CONNECTION failure — not statement 0 failing. Misclassifying it as an exec error + // would drop the connect suggestion and make the migration-apply formatter blame + // the migration's first statement for the database being unreachable. + Effect.gen(function* () { + const server = yield* Effect.promise(() => fakeBatchServer({ stall: true })); + const error = yield* runWithBatchServer(server, (session) => + Effect.gen(function* () { + // Interrupting a batch discards its pooled connection, so the next batch has + // to dial again — and by then the server has stopped listening. + yield* session + .execBatch([{ sql: "SELECT 1" }]) + .pipe(Effect.timeout(Duration.millis(100)), Effect.ignore); + yield* Effect.sync(server.close); + return yield* session.execBatch([{ sql: "SELECT 1" }]).pipe(Effect.flip); + }), + ); + expect(error._tag).toBe("LegacyDbConnectError"); + if (error._tag === "LegacyDbConnectError") { + expect(error.message).toBe( + "failed to connect to postgres: failed to connect to `host=127.0.0.1 user=postgres database=postgres`: " + + `dial error (connect ECONNREFUSED 127.0.0.1:${server.port})`, ); - }); + expect(error.suggestion).toBe(LEGACY_SUGGEST_LOCAL_STACK); + } }), ); }); 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 3da257ab63..8f56cb1166 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 @@ -961,7 +961,15 @@ const connect = ( return fresh; }); - const acquireBatchClient = Effect.callback((resume) => { + // Checking a connection out of the pool for a batch is a connection-setup + // concern, so it fails with `LegacyDbConnectError` — the same classification + // `acquireRawClient` uses above, and for the same reason: the pool may have to + // redial (its single connection is discarded after an interrupted or poisoned + // batch), and a refused/auth/DNS failure there is not a statement failure. Mapping + // it to `LegacyDbExecError` would lose the connect suggestion and make the + // migration-apply formatter blame the batch's first statement for a connectivity + // problem. Only the batch's own execution (below) raises `LegacyDbExecError`. + const acquireBatchClient = Effect.callback((resume) => { let done = false; try { pool.connect((error, activeClient) => { @@ -971,10 +979,12 @@ const connect = ( } done = true; if (error !== undefined) { - resume(Effect.fail(legacyToExecError(error))); + resume(Effect.fail(legacyToConnectError(cfg, options.isLocal, error))); } else if (activeClient === undefined) { resume( - Effect.fail(new LegacyDbExecError({ message: "failed to acquire batch connection" })), + Effect.fail( + new LegacyDbConnectError({ message: "failed to acquire batch connection" }), + ), ); } else { resume(Effect.succeed(activeClient)); @@ -982,7 +992,7 @@ const connect = ( }); } catch (error) { done = true; - resume(Effect.fail(legacyToExecError(error))); + resume(Effect.fail(legacyToConnectError(cfg, options.isLocal, error))); } return Effect.sync(() => { done = true; diff --git a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts index 5d0b9aca0c..ee50793394 100644 --- a/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migrate-and-seed.unit.test.ts @@ -22,11 +22,17 @@ const isRoot = typeof process.getuid === "function" && process.getuid() === 0; function fakeSession() { const execs: Array = []; + // A file's statements travel as one batch, so both entry points record into + // `execs` — the assertions below only care about which SQL reached the database. const session: LegacyDbSession = { exec: (sql) => Effect.sync(() => { execs.push(sql); }), + execBatch: (statements) => + Effect.sync(() => { + for (const { sql } of statements) execs.push(sql); + }), query: () => Effect.succeed([]), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), @@ -35,16 +41,20 @@ function fakeSession() { return { session, execs }; } -/** Every statement fails except the `BEGIN`/`COMMIT`/`ROLLBACK` transaction control Go wraps it in. */ +/** Every statement fails, whether it runs standalone or inside a batch. */ function failingExecSession(): { session: LegacyDbSession; execs: Array } { const execs: Array = []; - const TRANSACTION_CONTROL = new Set(["BEGIN", "COMMIT", "ROLLBACK"]); + const syntaxError = () => new LegacyDbExecError({ message: "syntax error" }); const session: LegacyDbSession = { exec: (sql) => { execs.push(sql); - return TRANSACTION_CONTROL.has(sql) - ? Effect.sync(() => {}) - : Effect.fail(new LegacyDbExecError({ message: "syntax error" })); + return Effect.fail(syntaxError()); + }, + execBatch: (statements) => { + for (const { sql } of statements) execs.push(sql); + // The batch dies on its first statement, like a server ErrorResponse before + // any command completed. + return Effect.fail(new LegacyDbExecError({ message: "syntax error", statementIndex: 0 })); }, query: () => Effect.succeed([]), extensionExists: () => Effect.succeed(false), @@ -55,8 +65,11 @@ function failingExecSession(): { session: LegacyDbSession; execs: Array } function pgNetFailureSession(error: LegacyDbExecError): LegacyDbSession { + const failsOnPgNet = (sql: string): boolean => sql.includes("net.http_post"); return { - exec: (sql) => (sql.includes("net.http_post") ? Effect.fail(error) : Effect.void), + exec: (sql) => (failsOnPgNet(sql) ? Effect.fail(error) : Effect.void), + execBatch: (statements) => + statements.some(({ sql }) => failsOnPgNet(sql)) ? Effect.fail(error) : Effect.void, query: () => Effect.succeed([]), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index f0f12f38cd..6fb99cac7c 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -2,7 +2,7 @@ import { Data, Effect, type FileSystem, type Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; import { legacyBold } from "./legacy-colors.ts"; -import { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; +import { LegacyDbConnectError, LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import { actionability, type CliErrorActionabilityDeclaration, @@ -505,8 +505,7 @@ const formattedExecBatchDbError = (error: unknown): LegacyDbExecError | undefine * cannot run in a transaction block: the open batch is flushed (committed), the * statement runs standalone, then batching resumes (supabase/cli#5156). The history * insert goes in the final batch, so the migration is recorded only after every - * statement succeeds. A file with no such statements uses one batch and one Sync - * (a session without `execBatch` falls back to an explicit `BEGIN`/`COMMIT`). + * statement succeeds. A file with no such statements uses one batch and one Sync. * Pg-delta files whose first line is `-- pg-delta: transaction=false` instead run * every statement sequentially without a CLI-owned transaction. This keeps their * session preamble, nontransactional action, and cleanup on the same connection. @@ -529,7 +528,7 @@ const execMigrationBatch = ( forceNoVersion: boolean, displayPath: string = migrationPath, projectEnv: Readonly> = {}, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { // Receives an already-read/parsed file (the read // happens earlier, which wraps the open @@ -648,31 +647,15 @@ const execMigrationBatch = ( }); } const base = executed; - let completed = 0; - const execute = - session.execBatch === undefined - ? Effect.gen(function* () { - yield* session.exec("BEGIN"); - const body = Effect.gen(function* () { - for (const operation of operations) { - if (operation.params === undefined) { - yield* session.exec(operation.sql); - } else { - yield* session.query(operation.sql, operation.params); - } - completed += 1; - } - yield* session.exec("COMMIT"); - }); - yield* body.pipe( - Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore)), - ); - }) - : session.execBatch(operations); - - yield* execute.pipe( + yield* session.execBatch(operations).pipe( Effect.mapError((cause) => { - const globalIndex = base + (cause.statementIndex ?? completed); + // Acquiring the batch's connection failed: there is no failing + // statement to name, so the connect error (and its suggestion) is + // surfaced verbatim instead of being rendered as `At statement: N`. + if (cause instanceof LegacyDbConnectError) return cause; + // `statementIndex` is set by every batch failure the driver raises; a + // session that omits it can only have failed before the first statement. + const globalIndex = base + (cause.statementIndex ?? 0); return legacyFormatExecBatchError( cause, globalIndex, @@ -701,7 +684,13 @@ const execMigrationBatch = ( yield* flushBatch(version.length > 0); }).pipe( Effect.mapError((error) => - mapError(legacyErrorMessage(error), "exec", formattedExecBatchDbError(error)), + // A batch connection failure is not an execution failure: it keeps its own + // error class (and `suggestion`) all the way out, exactly like the connect + // failure a caller would have seen from `connect` itself, instead of being + // relabeled as this file's statement-execution failure. + error instanceof LegacyDbConnectError + ? error + : mapError(legacyErrorMessage(error), "exec", formattedExecBatchDbError(error)), ), ); }); @@ -737,7 +726,7 @@ export const legacyApplyMigrationFile = ( path: Path.Path, migrationPath: string, mapError: (message: string, dbError?: LegacyDbExecError) => E, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { yield* resetConnectionState(session, mapError); yield* legacyCreateMigrationTable(session).pipe( @@ -765,7 +754,7 @@ export const legacyApplyMigrations = ( path: Path.Path, pending: ReadonlyArray, mapError: (message: string) => E, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const output = yield* Output; if (pending.length === 0) return; @@ -793,7 +782,7 @@ export const legacySeedGlobals = ( path: Path.Path, globals: ReadonlyArray, mapError: (message: string) => E, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const output = yield* Output; for (const globalPath of globals) { @@ -829,7 +818,7 @@ export const legacyExecSqlFile = ( mapError: (message: string, phase: "read" | "exec") => E, displayPath?: string, projectEnv?: Readonly>, -): Effect.Effect => +): Effect.Effect => execMigrationBatch(session, fs, path, filePath, mapError, true, displayPath, projectEnv); /** @@ -876,7 +865,7 @@ export const legacyApplySchemaFiles = ( schemaPaths: ReadonlyArray, mapError: (message: string, suggestion?: string) => E, projectEnv: Readonly> = {}, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const { files, warnings } = yield* legacySqlFilesGlob(fs, path, schemaPaths, workdir); if (files.length === 0) { 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 07cb9120af..aceab90bed 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 @@ -11,6 +11,7 @@ import { type CliErrorActionabilityDeclaration, ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; +import type { LegacyDbConnectError } from "./legacy-db-connection.errors.ts"; import type { LegacyDbBatchStatement, LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacyApplyMigrationFile, @@ -101,7 +102,10 @@ const executedSql = ( : [], ); -const run = (session: LegacyDbSession, migrationPath: string): Effect.Effect => +const run = ( + session: LegacyDbSession, + migrationPath: string, +): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts index ac972b435d..7666fd518f 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-history.unit.test.ts @@ -18,6 +18,7 @@ const mig = (version: string) => `supabase/migrations/${version}_test.sql`; /** Minimal session whose `query` fails with the given error. */ const failingSession = (error: LegacyDbExecError): LegacyDbSession => ({ exec: () => Effect.die("unused"), + execBatch: () => Effect.die("unused"), query: () => Effect.fail(error), extensionExists: () => Effect.die("unused"), copyToCsv: () => Effect.die("unused"), diff --git a/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts b/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts index abbb82f506..f5288ffebb 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-ops.unit.test.ts @@ -18,6 +18,10 @@ function fakeSeedSession() { calls.push({ kind: "exec", sql }); return Effect.void; }, + execBatch: (statements) => { + for (const { sql } of statements) calls.push({ kind: "exec", sql }); + return Effect.void; + }, query: (sql) => { calls.push({ kind: "query", sql }); return Effect.succeed([]); diff --git a/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts b/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts index a42e208211..eb04c5bc8e 100644 --- a/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-seed.unit.test.ts @@ -13,6 +13,7 @@ function fakeSession() { const queries: Array<{ sql: string; params?: ReadonlyArray }> = []; const session: LegacyDbSession = { exec: () => Effect.void, + execBatch: () => Effect.void, query: (sql, params) => Effect.sync(() => { queries.push({ sql, params }); diff --git a/apps/cli/src/legacy/shared/legacy-test-db.integration.test.ts b/apps/cli/src/legacy/shared/legacy-test-db.integration.test.ts index 359340ff9e..8b466567c4 100644 --- a/apps/cli/src/legacy/shared/legacy-test-db.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-test-db.integration.test.ts @@ -84,6 +84,8 @@ function mockDbConnection(opts: { return yield* Effect.fail(new LegacyDbExecError({ message: "cannot drop" })); } }), + // `test db` never runs a migration batch; keep the seam explicit rather than silent. + execBatch: () => Effect.die("execBatch unused"), extensionExists: () => Effect.succeed(opts.existed ?? false), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), copyToCsv: () => Effect.succeed(new Uint8Array()), diff --git a/apps/cli/tests/helpers/legacy-mocks.ts b/apps/cli/tests/helpers/legacy-mocks.ts index 71e72f3959..fd9b230930 100644 --- a/apps/cli/tests/helpers/legacy-mocks.ts +++ b/apps/cli/tests/helpers/legacy-mocks.ts @@ -16,6 +16,11 @@ import * as UrlParams from "effect/unstable/http/UrlParams"; import { afterEach, beforeEach } from "vitest"; import { LegacyCredentials } from "../../src/legacy/auth/legacy-credentials.service.ts"; +import { LegacyDbExecError } from "../../src/legacy/shared/legacy-db-connection.errors.ts"; +import type { + LegacyDbBatchStatement, + LegacyDbSession, +} from "../../src/legacy/shared/legacy-db-connection.service.ts"; import { LegacyCredentialDeleteError, LegacyDeleteTokenError, @@ -667,6 +672,36 @@ export function mockLegacyPlatformApiService( return { layer, requests }; } +/** + * A `LegacyDbSession.execBatch` fake for mocks that only model statement-level + * behavior: it replays a batch's operations one at a time through the mock's own + * `exec` (no params) / `query` (params) fakes, and attaches the failing + * statement's index the way the real driver does. Mocks stay free of batch + * protocol details while `execs`/`queries` recordings and per-suite failure + * injection keep working for batched migration files. + */ +export function legacySequentialExecBatch( + session: Pick, +): (statements: ReadonlyArray) => Effect.Effect { + return (statements) => + Effect.forEach(statements, ({ sql, params }, index) => + (params === undefined ? session.exec(sql) : session.query(sql, params)).pipe( + Effect.asVoid, + Effect.mapError((cause) => + cause.statementIndex !== undefined + ? cause + : new LegacyDbExecError({ + message: cause.message, + ...(cause.code === undefined ? {} : { code: cause.code }), + ...(cause.detail === undefined ? {} : { detail: cause.detail }), + ...(cause.position === undefined ? {} : { position: cause.position }), + statementIndex: index, + }), + ), + ), + ).pipe(Effect.asVoid); +} + // --------------------------------------------------------------------------- // Temp workdir lifecycle — calls vitest beforeEach/afterEach internally, so // the helper must be invoked at module scope (or inside the surrounding